diff --git a/.gitignore b/.gitignore index eebe39f7..5c1d0bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ node_modules/ harness.trace.jsonl harness.trajectory.jsonl .harness/ -.graff/worktrees/ +.graff/ .claude/scheduled_tasks.lock *.session.json .mcp.json @@ -29,5 +29,9 @@ __pycache__/ # local agent/IDE config (not project source) .devin/ +# local scratch artifacts (plans/handoffs, visual-QA screenshots) +plans/ +shots/ + # release artifacts dist/ diff --git a/bun.lock b/bun.lock index f4d55ebd..6abf2eba 100644 --- a/bun.lock +++ b/bun.lock @@ -7,16 +7,12 @@ }, "gui": { "name": "codegraff-gui", - "version": "0.0.162", + "version": "0.0.166", "dependencies": { "@base-ui/react": "^1.4.0", "@codegraff/diffs": "workspace:*", "@fontsource-variable/geist": "^5.2.8", "@legendapp/list": "^3.0.0-beta.44", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-process": "^2.3.0", - "@tauri-apps/plugin-updater": "^2.9.0", "@wterm/dom": "^0.1.9", "@zane-chen/agents-are-thinking": "^0.1.6", "border-beam": "^1.0.1", @@ -39,13 +35,13 @@ "@eslint/js": "^9.39.4", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.2.2", - "@tauri-apps/cli": "^2.10.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", "bun-types": "^1.3.13", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", @@ -137,9 +133,9 @@ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], - "@base-ui/react": ["@base-ui/react@1.5.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.2.9", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-z1gSAlced1yY+iM+mHDEtIkD8UI3Ebs52MuBPxvV6f5hRutk+xvCH/wuB7hDqDzK9JG5FoMz5nhrqtSs1wjt1A=="], + "@base-ui/react": ["@base-ui/react@1.6.0", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@base-ui/utils": "0.3.1", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@date-fns/tz": "^1.2.0", "@types/react": "^17 || ^18 || ^19", "date-fns": "^4.0.0", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@date-fns/tz", "@types/react", "date-fns"] }, "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw=="], - "@base-ui/utils": ["@base-ui/utils@0.2.9", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-x/PDDCYzoqPpjrdyb3VcyylTI2IjUXEtYDGi5foh7KsnmNJIIaVwA2GLgDH1dps1GgXiJbA60hM+AyuTfQzIvw=="], + "@base-ui/utils": ["@base-ui/utils@0.3.1", "", { "dependencies": { "@babel/runtime": "^7.29.2", "@floating-ui/utils": "^0.2.11", "reselect": "^5.2.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], @@ -147,15 +143,67 @@ "@codegraff/diffs": ["@codegraff/diffs@workspace:packages/diffs"], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.73.1", "", { "dependencies": { "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-AWf8edAsSpINqJbtaZsaiV3PO+vw5pS4wJFl6ahYNdnp3gfFYeMxRCdsA9042bKP4UPCs8yr5FikSFlvkmFVag=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="], - "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], + "@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], "@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=="], @@ -211,19 +259,13 @@ "@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=="], - "@legendapp/list": ["@legendapp/list@3.0.6", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-umEkE+WQpw61Ep1lLQC6f/UzXl19Jxqysg+WUP5VnvfA9TxCCj0VsxU8n2ukEFTovSIrY3ab4aFNKgJt9P1Ngg=="], + "@legendapp/list": ["@legendapp/list@3.2.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-bN+g/oQYjFz+UAyuBN4cmYJAwdJS1TdNcZZOVlh3+VwCQUWrsg0PH46Mvm76gdZSCYMfoFanPY4dKnILcYEzeg=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], - - "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], - - "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], - - "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -231,37 +273,37 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.3", "", { "os": "linux", "cpu": "x64" }, "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.3", "", { "os": "none", "cpu": "arm64" }, "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.3", "", { "os": "none", "cpu": "arm64" }, "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.3", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.3", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.3", "", { "os": "win32", "cpu": "x64" }, "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA=="], "@rolldown/plugin-babel": ["@rolldown/plugin-babel@0.2.3", "", { "dependencies": { "picomatch": "^4.0.4" }, "peerDependencies": { "@babel/core": "^7.29.0 || ^8.0.0-rc.1", "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", "rolldown": "^1.0.0-rc.5", "vite": "^8.0.0" }, "optionalPeers": ["@babel/plugin-transform-runtime", "@babel/runtime", "vite"] }, "sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw=="], @@ -323,37 +365,37 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], - "@swc/core": ["@swc/core@1.15.41", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.41", "@swc/core-darwin-x64": "1.15.41", "@swc/core-linux-arm-gnueabihf": "1.15.41", "@swc/core-linux-arm64-gnu": "1.15.41", "@swc/core-linux-arm64-musl": "1.15.41", "@swc/core-linux-ppc64-gnu": "1.15.41", "@swc/core-linux-s390x-gnu": "1.15.41", "@swc/core-linux-x64-gnu": "1.15.41", "@swc/core-linux-x64-musl": "1.15.41", "@swc/core-win32-arm64-msvc": "1.15.41", "@swc/core-win32-ia32-msvc": "1.15.41", "@swc/core-win32-x64-msvc": "1.15.41" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-03nQq/082QRJJiOvp3FGbgxTGyyxMxohPTjhk/W9bD2J0tk4ukITI7goOhOO2WbaHn/lsPmo/zf8+DIXhwpgYQ=="], + "@swc/core": ["@swc/core@1.15.43", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.27" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.43", "@swc/core-darwin-x64": "1.15.43", "@swc/core-linux-arm-gnueabihf": "1.15.43", "@swc/core-linux-arm64-gnu": "1.15.43", "@swc/core-linux-arm64-musl": "1.15.43", "@swc/core-linux-ppc64-gnu": "1.15.43", "@swc/core-linux-s390x-gnu": "1.15.43", "@swc/core-linux-x64-gnu": "1.15.43", "@swc/core-linux-x64-musl": "1.15.43", "@swc/core-win32-arm64-msvc": "1.15.43", "@swc/core-win32-ia32-msvc": "1.15.43", "@swc/core-win32-x64-msvc": "1.15.43" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-1CuKjFkPxIgGdeHVuNbkxmBxkcbdc08u0aiI43pFq6yY1tTVKmXT9hFEooyyKs/sJ3xf1GPHyEwTtk9Xl8dvQw=="], - "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.41", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kREh6J5paQFvP3i7f/4FbqRNOJREutVFVOkder4GVyCBQ39YmER55cW/y1NNjwrchzFqgYswFn0mMDCqbqKzrw=="], + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.43", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v1aVuvXdo/BHxJzco9V2xpHrvwWmhfS8t6gziY5wJxd+Z2h8AeJRnAwPD8itCDaGXVBwJ/CaKfxEzTkG0Va0OA=="], - "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.41", "", { "os": "darwin", "cpu": "x64" }, "sha512-N8B56ESFazZAWZyIkecADSPCwlLEinW7QLMEeotCpv4J7VXwfH+OLkmRL8o96UZ+1355fwHxDTS6/wK7yucvkA=="], + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.43", "", { "os": "darwin", "cpu": "x64" }, "sha512-lp3d4Lamc8dt5huYdGLSR+9hLxmfr1jb0l+4XXG2zPqZwYWRN9R0U2qYoTrggiU2RWW0oV9VbWM3kBnqIc2kdQ=="], - "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.41", "", { "os": "linux", "cpu": "arm" }, "sha512-6XrId2fyle0mS5xxON8rU84mPd2Cq1kDJRj+4BnQKTd7u+2kSA6Ww+JkOP0iTNqOqt9OXhPOEAjBHAuonWcdCg=="], + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.43", "", { "os": "linux", "cpu": "arm" }, "sha512-JWTQQELtsG5GgphDrr/XqqmM2pDN3cZqbMS0Mrg+iTiXL3F74sn/S2IyYE/5u4h2KLkTf9qQ7dXyxsbx7YzkeA=="], - "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-ynLIarxlkVnqHn1D0fKOVht6mNU5ks6lrH+MY3kkS+XFaGGgDxFZVjWKJlkYTKm3RCvBTfA8Ng5fLufXheMRKQ=="], + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-B4otJRdPWIsmiSBf0uG7Z/+vMWmkufjz5MmYxubwKuZazDW14Zd3symga1N62QR4RT+kEFeHEgsXfZGyn/w0hw=="], - "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.41", "", { "os": "linux", "cpu": "arm64" }, "sha512-dXu/5vd4gh8symyhRF+4G7gOPkjmb4pONhh7sl+6GSiW0LOKZlfu5kXmyFbTz9smOT7jgr002qY9b1nujjXt2A=="], + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.43", "", { "os": "linux", "cpu": "arm64" }, "sha512-6zB6OnpViBxYy4tgY3v2i6AZY9fwkcHZ032UOwtwUuW1d19sdT07qF0kZe6/3UR1tUaK6jjg2rmVcUIBCEYVjQ=="], - "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.41", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XGO6zVPXoPE0gf/XnI4jBbafNT13AYgoh6ns0JCSdOetI/kqVf0vhpz7NuNgAzZrMVCsmieqjPoTwViDgh4mOQ=="], + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.43", "", { "os": "linux", "cpu": "ppc64" }, "sha512-coxE1ZWdB3uSDVNoEtYNrRi/1epvckZx9cTJ8ICUxTMTxGk+yvQ/Twacp3ruZSaMPGCriUjP86C37VhaT6nyRg=="], - "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.41", "", { "os": "linux", "cpu": "s390x" }, "sha512-0WUglRwyZtW+iMi7J3iFdrCxreZZIKf4egTwEQfIYRsqFax69A0OrFj+NIoFSE03xBT/IFRrg+S8K6f9Ky+4hA=="], + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.43", "", { "os": "linux", "cpu": "s390x" }, "sha512-lXfLhs+LpBsD5inuYx+YDH5WsPPBQ95KPUiy8P5wq9ob9xKDZFqwNfU2QW6bGO8NqRO/H9JQomTSt5Yyh+FGfA=="], - "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.41", "", { "os": "linux", "cpu": "x64" }, "sha512-VxkuQK59c0tHm6uJZCUrS3cyA2JhGGfdU6e41SZz0x/JS+4Sm7C1mIc97In14vkZJopEt7yXA2TouCqZDSygEA=="], + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.43", "", { "os": "linux", "cpu": "x64" }, "sha512-07XnKwTmKy8TGOZG3D9fRnLWGynxPjwQnZLVmBFbo6F+7vHYzBIOuwXEhemrChBWb6yDNZsVCcMWCPX6FDD2xg=="], - "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.41", "", { "os": "linux", "cpu": "x64" }, "sha512-/0qXIu1ZxggLuovLb22vFfKHq2AA4n6Whw5UwmVCHk4pkw7KWnPIQpMCEqUMPsNkFJig7PPp/TSYFu8ZEb2rtQ=="], + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.43", "", { "os": "linux", "cpu": "x64" }, "sha512-TJc+bsSIaBh+hZvZ5GRtW/K1bw66TJ9vsUwvVIsZdiWxU5ObLwZvfcnZ3UpgVfMnFibRes9uriJrQNBHEEogRQ=="], - "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.41", "", { "os": "win32", "cpu": "arm64" }, "sha512-Y481sMNZM6rECh9VO4+y26N1lWEDAyxnBZskUf37fl90uHE946VHfmiVQWT0uMFOhyJJFovGTRuF4W82dwewUg=="], + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.43", "", { "os": "win32", "cpu": "arm64" }, "sha512-jfd7s2/bUQYkOHLs+LWQNKZdmDa8+sufKLllhpWAhVQ2GDCwsHe3vR/j+OSiItZNtkzFuaawa3+SAKz9y5gYfw=="], - "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.41", "", { "os": "win32", "cpu": "ia32" }, "sha512-BAchBD5qeUzy3hiPSLJtaaoSm4blCLyYffOF1bGE4ETcV+OisqjUAwDQMJj++4bTpvMCDzwC+Bj3PmQyBCtscw=="], + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.43", "", { "os": "win32", "cpu": "ia32" }, "sha512-rLAE8JvucqEW1ZGohxPQrQWPBQeJG4+ypKbWfdlU/qmKScvCkxf9/Jxnzki1dkUQCQ7P5Enp13RlvqOlvx/32g=="], - "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.41", "", { "os": "win32", "cpu": "x64" }, "sha512-WOkA+fJ/ViVBQDsSV9JC52NACTe5PhlurA6viASDZGb7HR3KS01ZG7RZ+Bg6SVQFIoq3gSbTsskQVe6EbHFAYw=="], + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.43", "", { "os": "win32", "cpu": "x64" }, "sha512-h8MLDHZcfIukwQWj03rIJZx1I0E81AYj2X7J/nGErG4nz+QAv6G1Z+peotvinL3lqpbo32tLYSMFo32/ySzxKg=="], "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/types": ["@swc/types@0.1.27", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg=="], - "@swc/wasm": ["@swc/wasm@1.15.41", "", {}, "sha512-wFocmIRY5pSvqxzvJbsKiHyP6oNrSPqBbicF1TV3n0E1ZekSQm/hG9cbRYwxd2pVGL4Nmg8JNpIeLSwRS97Gdg=="], + "@swc/wasm": ["@swc/wasm@1.15.43", "", {}, "sha512-jYqeckrzZGAU+9OSfmL15MWfhkKWRCC8QMssL/MZu/MaIP76mU3VHjzqxlwKIz9DOSR/9jCqi1+QlWE0ILNehA=="], "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], @@ -385,41 +427,9 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.1", "", { "dependencies": { "@tailwindcss/node": "4.3.1", "@tailwindcss/oxide": "4.3.1", "tailwindcss": "4.3.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ=="], - "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], - - "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], - - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], - - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="], - - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="], - - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="], - - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="], - - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="], - - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="], - - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="], - - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="], - - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="], - - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], - - "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.7.1", "", { "dependencies": { "@tauri-apps/api": "^2.11.0" } }, "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ=="], - - "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="], - - "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], - "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -503,29 +513,29 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/utils": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.62.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/type-utils": "8.62.0", "@typescript-eslint/utils": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.62.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.62.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.1", "@typescript-eslint/types": "^8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.62.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.62.0", "@typescript-eslint/types": "^8.62.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1" } }, "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0" } }, "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.62.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.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-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/utils": "8.62.0", "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-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.61.1", "", {}, "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.62.0", "", {}, "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.61.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.61.1", "@typescript-eslint/tsconfig-utils": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.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-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.62.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.62.0", "@typescript-eslint/tsconfig-utils": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/visitor-keys": "8.62.0", "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-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.62.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.1", "", { "dependencies": { "@typescript-eslint/types": "8.61.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.62.0", "", { "dependencies": { "@typescript-eslint/types": "8.62.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.3", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg=="], "@wterm/core": ["@wterm/core@0.1.9", "", {}, "sha512-RqyS/w7SU3sLCY2yfsw5p+T7wYIIgCYzOMQIWkoJHKN7o+n/uerr2nTNygR3MBQ2FOcznilFm30w9aOJ2OK3GQ=="], @@ -539,8 +549,6 @@ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@6.15.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-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -571,7 +579,7 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.37", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.40", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw=="], "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], @@ -583,7 +591,7 @@ "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], - "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=="], + "browserslist": ["browserslist@4.28.4", "", { "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", "electron-to-chromium": "^1.5.376", "node-releases": "^2.0.48", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], @@ -731,8 +739,6 @@ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], @@ -789,11 +795,9 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "eciesjs": ["eciesjs@0.4.18", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ=="], - "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], + "electron-to-chromium": ["electron-to-chromium@1.5.380", "", {}, "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -823,9 +827,11 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "es-to-primitive": ["es-to-primitive@1.3.1", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g=="], + "es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="], + + "es-toolkit": ["es-toolkit@1.49.0", "", {}, "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g=="], - "es-toolkit": ["es-toolkit@1.47.1", "", {}, "sha512-5RAqEwf4P4E17p+W75KLOWw/nOvKZzSQpxM32IpI2KZLaVonjTrZ0Ai5ghMaVI9eKC2p8eoQgcBdkEDgzFk6+Q=="], + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -881,8 +887,6 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], - "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -899,8 +903,6 @@ "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -935,7 +937,7 @@ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="], + "globals": ["globals@17.7.0", "", {}, "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg=="], "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], @@ -963,12 +965,10 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "hono": ["hono@4.12.27", "", {}, "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -985,7 +985,7 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - "internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], @@ -1075,7 +1075,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -1141,7 +1141,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@1.20.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-jhXLeC/7m0/tjL1nzMdKk6x256zWA6AtbhTVreHOiKPoeX2d6MK4FbyIQPpVq0E6iPWBisyy1TW+pEge/uMEuQ=="], + "lucide-react": ["lucide-react@1.21.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1159,7 +1159,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], + "mermaid": ["mermaid@11.16.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], @@ -1179,17 +1179,13 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.15", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - - "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + "node-releases": ["node-releases@2.0.50", "", {}, "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg=="], "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], @@ -1281,11 +1277,11 @@ "q": ["q@1.5.1", "", {}, "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw=="], - "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1295,7 +1291,7 @@ "react-resizable-panels": ["react-resizable-panels@4.11.2", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg=="], - "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + "recast": ["recast@0.23.12", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], @@ -1313,7 +1309,7 @@ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], - "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rolldown": ["rolldown@1.1.3", "", { "dependencies": { "@oxc-project/types": "=0.137.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.3", "@rolldown/binding-darwin-arm64": "1.1.3", "@rolldown/binding-darwin-x64": "1.1.3", "@rolldown/binding-freebsd-x64": "1.1.3", "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", "@rolldown/binding-linux-arm64-gnu": "1.1.3", "@rolldown/binding-linux-arm64-musl": "1.1.3", "@rolldown/binding-linux-ppc64-gnu": "1.1.3", "@rolldown/binding-linux-s390x-gnu": "1.1.3", "@rolldown/binding-linux-x64-gnu": "1.1.3", "@rolldown/binding-linux-x64-musl": "1.1.3", "@rolldown/binding-openharmony-arm64": "1.1.3", "@rolldown/binding-wasm32-wasi": "1.1.3", "@rolldown/binding-win32-arm64-msvc": "1.1.3", "@rolldown/binding-win32-x64-msvc": "1.1.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g=="], "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], @@ -1355,7 +1351,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.11.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ=="], + "shadcn": ["shadcn@4.12.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "undici": "^7.27.2", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-o781ieQziCnXH2FKsEqxp1fnbHdbgAPO9inTSPeZ59hQfsZXuMGp3ul8oFSV5KQS4nbUK9b+DrDE6C7OvfKKQQ=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1411,7 +1407,7 @@ "svgo": ["svgo@1.3.2", "", { "dependencies": { "chalk": "^2.4.1", "coa": "^2.0.2", "css-select": "^2.0.0", "css-select-base-adapter": "^0.1.1", "css-tree": "1.0.0-alpha.37", "csso": "^4.0.2", "js-yaml": "^3.13.1", "mkdirp": "~0.5.1", "object.values": "^1.1.0", "sax": "~1.2.4", "stable": "^0.1.8", "unquote": "~1.1.1", "util.promisify": "~1.0.0" }, "bin": { "svgo": "./bin/svgo" } }, "sha512-yhy/sQYxR5BkC98CY7o31VGsg014AKLEPxdfhora76l36hD9Rdy5NZA/Ocn6yayNPgSamYdtX2rFJdcv07AYVw=="], - "systeminformation": ["systeminformation@5.31.7", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-/8NC53e5nP9nmhn42/ncdOkyJnOoue/Vy+tJOyUGd1Yv66G069wK4rrziwhrqDETgk78CudTQupw5z19S5uoZw=="], + "systeminformation": ["systeminformation@5.31.11", "", { "os": "!aix", "bin": { "systeminformation": "lib/cli.js" } }, "sha512-I6O7iaUj23AXRgCPDDnvi3xHvdOLp4+1YMbF+X194lJwY1NeWojgHJPhslVKcmTtrLTguRk3QJK+xEdTiI3P0w=="], "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], @@ -1457,7 +1453,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.61.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.61.1", "@typescript-eslint/parser": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/utils": "8.61.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw=="], + "typescript-eslint": ["typescript-eslint@8.62.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.62.0", "@typescript-eslint/parser": "8.62.0", "@typescript-eslint/typescript-estree": "8.62.0", "@typescript-eslint/utils": "8.62.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q=="], "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], @@ -1489,14 +1485,12 @@ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.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", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], "vite-plugin-top-level-await": ["vite-plugin-top-level-await@1.6.0", "", { "dependencies": { "@rollup/plugin-virtual": "^3.0.2", "@swc/core": "^1.12.14", "@swc/wasm": "^1.12.14", "uuid": "10.0.0" }, "peerDependencies": { "vite": ">=2.8" } }, "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww=="], "vite-plugin-wasm": ["vite-plugin-wasm@3.6.0", "", { "peerDependencies": { "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8" } }, "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw=="], - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], @@ -1521,7 +1515,7 @@ "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], @@ -1549,9 +1543,9 @@ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.2" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -1561,7 +1555,7 @@ "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/typescript-estree/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], @@ -1579,7 +1573,7 @@ "conf/json-schema-typed": ["json-schema-typed@7.0.3", "", {}, "sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A=="], - "conf/semver": ["semver@7.8.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA=="], + "conf/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "csso/css-tree": ["css-tree@1.1.3", "", { "dependencies": { "mdn-data": "2.0.14", "source-map": "^0.6.1" } }, "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q=="], @@ -1625,11 +1619,9 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "svgo/chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], - "svgo/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "svgo/js-yaml": ["js-yaml@3.15.0", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog=="], "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], @@ -1671,6 +1663,8 @@ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], "enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/gui/.gitignore b/gui/.gitignore index ab316667..79b93cad 100644 --- a/gui/.gitignore +++ b/gui/.gitignore @@ -11,8 +11,6 @@ lerna-debug.log* node_modules dist dist-ssr -src-tauri/target -src-tauri/gen *.local # Editor directories and files diff --git a/gui/api/stub.zig b/gui/api/stub.zig new file mode 100644 index 00000000..386b8b3f --- /dev/null +++ b/gui/api/stub.zig @@ -0,0 +1,9 @@ +const mer = @import("mer"); +const backend = @import("backend"); + +pub fn render(req: mer.Request) mer.Response { + const rt = backend.instance orelse { + return mer.Response.init(.internal_server_error, .json, "{\"error\":\"backend runtime is not initialized\"}"); + }; + return rt.handleApi(req); +} diff --git a/gui/build.zig b/gui/build.zig new file mode 100644 index 00000000..1f4d2664 --- /dev/null +++ b/gui/build.zig @@ -0,0 +1,177 @@ +// build.zig — codegraff-zig GUI native app (merjs consumer). +// +// Builds the native desktop shell: a merjs HTTP server (serving the Vite-built +// React SPA from dist/ + api/ backend routes + SSE /events) hosted in a system +// WKWebView. The React frontend (src/) is unchanged; only its runtime host +// changes from the old desktop backend to merjs. +// +// zig build native # dev: run the shell (serves dist/) +// zig build native-build # prod: build the binary +// zig build package # bundle as Codegraff.app +// +// Prereq: `bun run build` produces dist/ (the React SPA). +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const merjs = b.dependency("merjs", .{ + .target = target, + .optimize = optimize, + }); + const mer_mod = merjs.module("mer"); + const runtime_mod = merjs.module("runtime"); + + // native/main.zig — shell entry point. + const native_mod = b.createModule(.{ + .root_source_file = b.path("native/main.zig"), + .target = target, + .optimize = optimize, + }); + native_mod.addImport("mer", mer_mod); + native_mod.addImport("runtime", runtime_mod); + + // manifest — mer.app.zon (comptime config for the shell). + const manifest_mod = b.createModule(.{ + .root_source_file = b.path("mer.app.zon"), + }); + native_mod.addImport("manifest", manifest_mod); + + // Local PTY module: vendored/adapted openpty + Codegraff-owned shell process lifecycle. + const pty_mod = b.createModule(.{ + .root_source_file = b.path("src-backend/pty/root.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + + // Backend runtime module (the port of runtime/simple.rs). + const backend_mod = b.createModule(.{ + .root_source_file = b.path("src-backend/runtime.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + backend_mod.addImport("mer", mer_mod); + backend_mod.addImport("runtime", runtime_mod); + backend_mod.addImport("pty", pty_mod); + native_mod.addImport("backend", backend_mod); + + // routes — the app's route table. We have api/ backend routes only (no SSR + // pages; the React SPA owns client rendering). Wire each api/*.zig as a + // named import on the routes module, then add routes.zig as "routes". + const routes_mod = b.createModule(.{ + .root_source_file = b.path("routes.zig"), + }); + routes_mod.addImport("mer", mer_mod); + routes_mod.addImport("backend", backend_mod); + // Scan api/ and add each .zig as "api/". + addApiModules(b, routes_mod, mer_mod, runtime_mod, backend_mod, "api"); + native_mod.addImport("routes", routes_mod); + + // macOS frameworks for the WKWebView shell. + native_mod.linkFramework("AppKit", .{}); + native_mod.linkFramework("WebKit", .{}); + native_mod.linkFramework("Foundation", .{}); + native_mod.link_libc = true; + + const exe = b.addExecutable(.{ .name = "codegraff-gui", .root_module = native_mod }); + b.installArtifact(exe); + + // `native` — run (dev). + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + const run_step = b.step("native", "Run the codegraff GUI native shell"); + run_step.dependOn(&run_cmd.step); + + // `native-build` — install only (prod). + const build_step = b.step("native-build", "Build the native shell binary (prod)"); + build_step.dependOn(b.getInstallStep()); + + const backend_tests = b.addTest(.{ .root_module = backend_mod }); + const run_backend_tests = b.addRunArtifact(backend_tests); + const test_step = b.step("test", "Run GUI backend unit tests"); + test_step.dependOn(&run_backend_tests.step); + + // `package` — .app bundle with manifest-driven Info.plist. + const app_zon = @import("mer.app.zon"); + const pkg_name = b.fmt("{s}.app", .{app_zon.display_name}); + const plist_xml = b.fmt( + \\ + \\ + \\ + \\ + \\ CFBundleExecutable codegraff-gui + \\ CFBundleIdentifier {s} + \\ CFBundleName {s} + \\ CFBundleVersion {s} + \\ CFBundleIconFile icon.icns + \\ NSHighResolutionCapable + \\ NSPrincipalClass NSApplication + \\ + \\ + , .{ app_zon.id, app_zon.display_name, app_zon.version }); + const plist = b.addWriteFile(b.fmt("{s}/Contents/Info.plist", .{pkg_name}), plist_xml); + const pkg_bin = b.addInstallFile( + exe.getEmittedBin(), + b.fmt("{s}/Contents/MacOS/codegraff-gui", .{pkg_name}), + ); + pkg_bin.step.dependOn(b.getInstallStep()); + const clean_pkg = b.addSystemCommand(&.{ + "rm", + "-rf", + b.getInstallPath(.prefix, pkg_name), + }); + const pkg_icon = b.addInstallFile( + b.path("native/icons/icon.icns"), + b.fmt("{s}/Contents/Resources/icon.icns", .{pkg_name}), + ); + const pkg_dist = b.addInstallDirectory(.{ + .source_dir = b.path("dist"), + .install_dir = .prefix, + .install_subdir = b.fmt("{s}/Contents/Resources/dist", .{pkg_name}), + }); + const pkg_plist = b.addInstallDirectory(.{ + .source_dir = plist.getDirectory(), + .install_dir = .prefix, + .install_subdir = "", + }); + const package_step = b.step("package", "Package the GUI as a .app bundle (macOS)"); + pkg_bin.step.dependOn(&clean_pkg.step); + pkg_icon.step.dependOn(&clean_pkg.step); + pkg_dist.step.dependOn(&clean_pkg.step); + pkg_plist.step.dependOn(&clean_pkg.step); + package_step.dependOn(&pkg_bin.step); + package_step.dependOn(&pkg_icon.step); + package_step.dependOn(&pkg_dist.step); + package_step.dependOn(&pkg_plist.step); +} + +/// Scan `dir/` for *.zig files and add each as a named module "api/" +/// on `mod`, with `mer` wired so route files can `@import("mer")`. +fn addApiModules( + b: *std.Build, + mod: *std.Build.Module, + mer_mod: *std.Build.Module, + runtime_mod: *std.Build.Module, + backend_mod: *std.Build.Module, + dir: []const u8, +) void { + var d = std.Io.Dir.cwd().openDir(b.graph.io, dir, .{ .iterate = true }) catch return; + defer d.close(b.graph.io); + var walker = d.walk(b.allocator) catch return; + defer walker.deinit(); + while (walker.next(b.graph.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.path, ".zig")) continue; + const file_path = b.fmt("{s}/{s}", .{ dir, entry.path }); + const import_name = b.fmt("api/{s}", .{entry.path[0 .. entry.path.len - 4]}); + const route_mod = b.createModule(.{ .root_source_file = b.path(file_path) }); + route_mod.addImport("mer", mer_mod); + route_mod.addImport("runtime", runtime_mod); + route_mod.addImport("backend", backend_mod); + mod.addImport(import_name, route_mod); + } +} diff --git a/gui/build.zig.zon b/gui/build.zig.zon new file mode 100644 index 00000000..bcbac80d --- /dev/null +++ b/gui/build.zig.zon @@ -0,0 +1,20 @@ +.{ + .name = .codegraff_gui, + .fingerprint = 0xbaf1ec805de39b91, + .version = "0.0.13", + .minimum_zig_version = "0.16.0", + .dependencies = .{ + .merjs = .{ + .path = "../../../merjs", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "mer.app.zon", + "native", + "api", + "src-backend", + "routes.zig", + }, +} diff --git a/gui/eslint.config.js b/gui/eslint.config.js index 3c90dbf4..a42814fb 100644 --- a/gui/eslint.config.js +++ b/gui/eslint.config.js @@ -6,12 +6,7 @@ import tseslint from "typescript-eslint"; import { defineConfig, globalIgnores } from "eslint/config"; export default defineConfig([ - globalIgnores([ - "dist", - "vendor/**", - "src-tauri/gen/**", - "src-tauri/target/**", - ]), + globalIgnores(["dist", "vendor/**"]), { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/gui/mer.app.zon b/gui/mer.app.zon new file mode 100644 index 00000000..3db90afd --- /dev/null +++ b/gui/mer.app.zon @@ -0,0 +1,39 @@ +// mer.app.zon — codegraff-zig GUI native app manifest. +// +// The merjs native shell reads this comptime: window size/title, server mode, +// static dir, and bridge permissions. The React SPA (built to dist/ by Vite) is +// served by the embedded merjs server; the Zig backend exposes api/ routes + +// an SSE /events channel for session-updated pushes. +.{ + .id = "dev.codegraff.gui", + .name = "codegraff-gui", + .display_name = "Codegraff", + .version = "0.0.13", + .web_engine = "system", + .server = .{ + .mode = "embedded", // in-process loopback server (no hot reload in prod) + .host = "127.0.0.1", + .port = 0, // 0 = ephemeral; shell reads back via ServerReady + .static_dir = "dist", // serve the Vite-built React SPA + }, + .capabilities = .{ "webview", "js_bridge" }, + .permissions = .{ "window", "clipboard", "dialog", "open" }, + .security = .{ + .navigation = .{ .allowed_origins = .{ "http://127.0.0.1" } }, + }, + .windows = .{ + .{ .label = "main", .title = "Codegraff", .width = 1280, .height = 832 }, + }, + .bridge = .{ + .commands = .{ + .{ .name = "dialog.openFile", .permissions = .{ "dialog" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "dialog.pickDirectory", .permissions = .{ "dialog" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "clipboard.read", .permissions = .{ "clipboard" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "clipboard.write", .permissions = .{ "clipboard" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "open.external", .permissions = .{ "open" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "open.path", .permissions = .{ "open" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "window.setTitle", .permissions = .{ "window" }, .origins = .{ "http://127.0.0.1" } }, + .{ .name = "window.close", .permissions = .{ "window" }, .origins = .{ "http://127.0.0.1" } }, + }, + }, +} diff --git a/gui/src-tauri/icons/icon.icns b/gui/native/icons/icon.icns similarity index 100% rename from gui/src-tauri/icons/icon.icns rename to gui/native/icons/icon.icns diff --git a/gui/native/main.zig b/gui/native/main.zig new file mode 100644 index 00000000..6e0b6239 --- /dev/null +++ b/gui/native/main.zig @@ -0,0 +1,76 @@ +// native/main.zig — codegraff-gui native shell entry point. +// +// Boots the merjs HTTP server (serving the React SPA from dist/ + api/ routes +// + SSE /events) inside a system WKWebView. The React frontend talks to this +// Zig backend over HTTP. +// +// zig build native # run +// zig build native-build # prod binary +// zig build package # .app bundle +const std = @import("std"); +const mer = @import("mer"); +const runtime = @import("runtime"); +const native = mer.native; +const backend = @import("backend"); + +const log = std.log.scoped(.native); + +pub fn main(init: std.process.Init.Minimal) !void { + var gpa: std.heap.DebugAllocator(.{}) = .init; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + var arena_state: std.heap.ArenaAllocator = .init(std.heap.page_allocator); + defer arena_state.deinit(); + const args = try init.args.toSlice(arena_state.allocator()); + + var app_manifest = native.Manifest.fromZon(@import("manifest")); + + // CLI overrides. + var i: usize = 1; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--dev")) { + app_manifest.dev = true; + app_manifest.server_mode = "dev"; + } else if (std.mem.eql(u8, args[i], "--no-dev")) { + app_manifest.dev = false; + app_manifest.server_mode = "embedded"; + } + } + + if (!app_manifest.dev) { + if (bundleResourcePath(allocator, args[0], "dist")) |bundle_dist| { + app_manifest.static_dir = bundle_dist; + } else |_| {} + } + + // Initialize the backend runtime (Phase 1 stub; Phase 2 = real RuntimeManager). + var rt = try backend.Runtime.init(allocator); + defer rt.deinit(); + + // Build the router from our api/ routes. + var router = mer.Router.fromGenerated(allocator, @import("routes")); + defer router.deinit(); + + // Register the SSE /events raw handler (checked before routing). + const raw_handler = mer.RawHandler{ + .ctx = @ptrCast(rt), + .callback = &backend.handleEvents, + }; + + log.info("codegraff-gui native — {s} v{s} ({s}, static={s})", .{ + app_manifest.display_name, + app_manifest.version, + app_manifest.server_mode, + app_manifest.static_dir orelse "public", + }); + try native.Shell.run(allocator, app_manifest, &router, .{ + .raw_handler = &raw_handler, + }); +} + +fn bundleResourcePath(allocator: std.mem.Allocator, exe_path: []const u8, resource_name: []const u8) ![]const u8 { + const marker = "/Contents/MacOS/"; + const idx = std.mem.lastIndexOf(u8, exe_path, marker) orelse return error.NotAppBundle; + return std.fmt.allocPrint(allocator, "{s}/Contents/Resources/{s}", .{ exe_path[0..idx], resource_name }); +} diff --git a/gui/package.json b/gui/package.json index cffe8273..1c2cabf8 100644 --- a/gui/package.json +++ b/gui/package.json @@ -7,12 +7,10 @@ "type": "module", "scripts": { "dev": "bun run build:deps && vite", - "gen:desktop-contracts": "cargo run --quiet --manifest-path src-tauri/Cargo.toml --example generate_desktop_contracts", "build:deps": "bun run --filter '@codegraff/diffs' build", - "build": "bun run build:deps && bun run gen:desktop-contracts && tsc -b && vite build", - "lint": "bun run build:deps && bun run gen:desktop-contracts && eslint .", + "build": "bun run build:deps && tsc -b && vite build", + "lint": "bun run build:deps && eslint .", "preview": "vite preview", - "tauri": "tauri", "test": "bun test" }, "dependencies": { @@ -20,10 +18,6 @@ "@codegraff/diffs": "workspace:*", "@fontsource-variable/geist": "^5.2.8", "@legendapp/list": "^3.0.0-beta.44", - "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-dialog": "^2.7.0", - "@tauri-apps/plugin-process": "^2.3.0", - "@tauri-apps/plugin-updater": "^2.9.0", "@wterm/dom": "^0.1.9", "@zane-chen/agents-are-thinking": "^0.1.6", "border-beam": "^1.0.1", @@ -46,13 +40,13 @@ "@eslint/js": "^9.39.4", "@rolldown/plugin-babel": "^0.2.3", "@tailwindcss/vite": "^4.2.2", - "@tauri-apps/cli": "^2.10.1", "@types/node": "^24.12.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "babel-plugin-react-compiler": "^1.0.0", "bun-types": "^1.3.13", + "esbuild": "^0.28.1", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.2", diff --git a/gui/routes.zig b/gui/routes.zig new file mode 100644 index 00000000..d342f24e --- /dev/null +++ b/gui/routes.zig @@ -0,0 +1,69 @@ +// routes.zig — codegraff-gui backend route table (Phase 1 stubs). +// +// Each backend command is POST /api/ -> api/stub.render. +// Native OS operations (dialogs, clipboard, open path/URL) use window.mer.invoke, +// not HTTP. SSE /events is the raw_handler. +const mer = @import("mer"); +const stub = @import("api/stub"); + +pub const routes: []const mer.Route = &.{ + .{ .path = "/api/open_workspace", .render = stub.render }, + .{ .path = "/api/get_runtime_status", .render = stub.render }, + .{ .path = "/api/get_session_snapshot", .render = stub.render }, + .{ .path = "/api/get_prompt_settings", .render = stub.render }, + .{ .path = "/api/get_theme_settings", .render = stub.render }, + .{ .path = "/api/update_theme_settings", .render = stub.render }, + .{ .path = "/api/list_providers", .render = stub.render }, + .{ .path = "/api/list_commands", .render = stub.render }, + .{ .path = "/api/select_conversation", .render = stub.render }, + .{ .path = "/api/ensure_conversation_view", .render = stub.render }, + .{ .path = "/api/start_new_chat", .render = stub.render }, + .{ .path = "/api/create_managed_chat", .render = stub.render }, + .{ .path = "/api/handoff_chat", .render = stub.render }, + .{ .path = "/api/send_prompt", .render = stub.render }, + .{ .path = "/api/stop_prompt", .render = stub.render }, + .{ .path = "/api/compact_conversation", .render = stub.render }, + .{ .path = "/api/run_slash_command", .render = stub.render }, + .{ .path = "/api/workspace_sync", .render = stub.render }, + .{ .path = "/api/workspace_query", .render = stub.render }, + .{ .path = "/api/build_workflow_draft", .render = stub.render }, + .{ .path = "/api/export_workflow_draft", .render = stub.render }, + .{ .path = "/api/set_active_agent", .render = stub.render }, + .{ .path = "/api/set_effort", .render = stub.render }, + .{ .path = "/api/set_fast", .render = stub.render }, + .{ .path = "/api/save_pasted_image", .render = stub.render }, + .{ .path = "/api/save_attachment_file", .render = stub.render }, + .{ .path = "/api/image_thumbnail", .render = stub.render }, + .{ .path = "/api/terminal_open", .render = stub.render }, + .{ .path = "/api/terminal_write", .render = stub.render }, + .{ .path = "/api/terminal_resize", .render = stub.render }, + .{ .path = "/api/terminal_close", .render = stub.render }, + .{ .path = "/api/list_mcp_servers", .render = stub.render }, + .{ .path = "/api/import_mcp_config", .render = stub.render }, + .{ .path = "/api/remove_mcp_server", .render = stub.render }, + .{ .path = "/api/reload_mcp_servers", .render = stub.render }, + .{ .path = "/api/login_mcp_server", .render = stub.render }, + .{ .path = "/api/logout_mcp_server", .render = stub.render }, + .{ .path = "/api/update_prompt_settings", .render = stub.render }, + .{ .path = "/api/start_provider_auth", .render = stub.render }, + .{ .path = "/api/complete_provider_auth", .render = stub.render }, + .{ .path = "/api/remove_provider", .render = stub.render }, + .{ .path = "/api/respond_followup", .render = stub.render }, + .{ .path = "/api/archive_conversation", .render = stub.render }, + .{ .path = "/api/archive_workspace", .render = stub.render }, + .{ .path = "/api/rename_workspace", .render = stub.render }, + .{ .path = "/api/clone_repository", .render = stub.render }, + .{ .path = "/api/quick_start_project", .render = stub.render }, + .{ .path = "/api/checkout_git_branch", .render = stub.render }, + .{ .path = "/api/create_git_branch", .render = stub.render }, + .{ .path = "/api/commit_git_changes", .render = stub.render }, + .{ .path = "/api/push_git_branch", .render = stub.render }, + .{ .path = "/api/read_workspace_file", .render = stub.render }, + .{ .path = "/api/save_conversation_layout", .render = stub.render }, + .{ .path = "/api/get_conversation_layout", .render = stub.render }, + .{ .path = "/api/create_saved_workspace", .render = stub.render }, + .{ .path = "/api/update_saved_workspace_layout", .render = stub.render }, + .{ .path = "/api/get_saved_workspace", .render = stub.render }, + .{ .path = "/api/rename_saved_workspace", .render = stub.render }, + .{ .path = "/api/delete_saved_workspace", .render = stub.render }, +}; diff --git a/gui/src-backend/pty/getpt.zig b/gui/src-backend/pty/getpt.zig new file mode 100644 index 00000000..e3f48f03 --- /dev/null +++ b/gui/src-backend/pty/getpt.zig @@ -0,0 +1,16 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; + +const posix_openpt = @import("posix_openpt.zig").posix_openpt; + +pub fn getpt() posix.OpenError!posix.fd_t { + return posix_openpt(.{ + .ACCMODE = .RDWR, + .NOCTTY = builtin.os.tag == .macos, + }); +} diff --git a/gui/src-backend/pty/grantpt.zig b/gui/src-backend/pty/grantpt.zig new file mode 100644 index 00000000..6fbf51bf --- /dev/null +++ b/gui/src-backend/pty/grantpt.zig @@ -0,0 +1,31 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const linux = std.os.linux; + +const pictl = @import("ioctl.zig"); + +const IoCtlError = pictl.IoCtlError; + +pub fn grantpt(fd: posix.fd_t) IoCtlError!void { + var ptyno: u32 = 0; + + const rc = switch (builtin.os.tag) { + .linux => linux.ioctl(fd, pictl.TIOCGPTN, @intFromPtr(&ptyno)), + .macos => std.c.ioctl(fd, 0x20007454), + else => @compileError("Unsupported os"), + }; + + switch (posix.errno(rc)) { + .SUCCESS => {}, + .BADF => return IoCtlError.InvalidFileDescriptor, + .FAULT => return IoCtlError.InaccessibleMemory, + .INVAL => return IoCtlError.BadRequistOrFlag, + .NOTTY => return IoCtlError.NotTTY, + else => return IoCtlError.Unexpcted, + } +} diff --git a/gui/src-backend/pty/ioctl.zig b/gui/src-backend/pty/ioctl.zig new file mode 100644 index 00000000..5bfdcaf0 --- /dev/null +++ b/gui/src-backend/pty/ioctl.zig @@ -0,0 +1,101 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); + +const _IOC_NRBITS = 8; +const _IOC_TYPEBITS = 8; +const _IOC_SIZEBITS = 14; +const _IOC_DIRBITS = 2; + +const _IOC_NRSHIFT = 0; +const _IOC_TYPESHIFT = _IOC_NRSHIFT + _IOC_NRBITS; +const _IOC_SIZESHIFT = _IOC_TYPESHIFT + _IOC_TYPEBITS; +const _IOC_DIRSHIFT = _IOC_SIZESHIFT + _IOC_SIZEBITS; + +const _IOC_IN = 0x80000000; +const _IOC_OUT = 0x40000000; +const _IOC_INOUT = (_IOC_OUT | _IOC_IN); + +const _IOC_NONE = 0; +const _IOC_WRITE = 1; +const _IOC_READ = 2; + +fn _IOC(dir: u32, typ: u32, nr: u32, size: u32) comptime_int { + // fn _IOC(dir: u64, typ: u64, nr: u64, size: u64) comptime_int { + return (dir << _IOC_DIRSHIFT) | (typ << _IOC_TYPESHIFT) | (nr << _IOC_NRSHIFT) | (size << _IOC_SIZESHIFT); +} + +pub fn _IO(typ: u8, nr: u8) comptime_int { + return _IOC( + _IOC_NONE, + @intCast(typ), + @intCast(nr), + 0, + ); +} + +pub fn _IOR(typ: u8, nr: u8, T: type) comptime_int { + return @intCast(_IOC( + _IOC_READ, + @intCast(typ), + @intCast(nr), + @intCast(@sizeOf(T)), + )); +} + +pub fn _IOW(typ: u8, nr: u8, T: type) comptime_int { + return @intCast(_IOC( + _IOC_WRITE, + @intCast(typ), + @intCast(nr), + @intCast(@sizeOf(T)), + )); +} + +pub fn _IOWR(typ: u8, nr: u8, T: type) comptime_int { + return @intCast(_IOC( + _IOC_READ | _IOC_WRITE, + @intCast(typ), + @intCast(nr), + @intCast(@sizeOf(T)), + )); +} + +pub const O_RDWR = 0x0002; +pub const O_NOCTTY = 0x0100; +pub const TCSAFLUSH = 0x2; + +pub const TIOCGPTN = _IOR('T', 0x30, u32); +pub const TIOCSPTLCK = _IOW('T', 0x31, i32); +pub const TIOCGPTPEER = _IO('T', 0x41); +pub const TIOCSWINSZ = _IOW('t', 0x67, std.posix.winsize); +pub const TIOCSCTTY = _IO('T', 0x0E); +pub const TIOCPTYGRANT = 0x20007454; +pub const TIOCPTYUNLK = 0x20007452; +pub const TIOCPTYGNAME = 0x40807453; + +pub const IoCtlError = error{ + InvalidFileDescriptor, + InaccessibleMemory, + BadRequistOrFlag, + NotTTY, + Unexpcted, +}; + +// test "Print ioctl constants" { +// const stdout = std.io.getStdOut().writer(); +// +// try stdout.print("O_RDWR: {x}\n", .{O_RDWR}); +// try stdout.print("O_NOCTTY: {x}\n", .{O_NOCTTY}); +// try stdout.print("TCSAFLUSH: {x}\n", .{TCSAFLUSH}); +// try stdout.print("TIOCGPTN: {x}\n", .{TIOCGPTN}); +// try stdout.print("TIOCSPTLCK: {x}\n", .{TIOCSPTLCK}); +// try stdout.print("TIOCGPTPEER: {x}\n", .{TIOCGPTPEER}); +// try stdout.print("TIOCSWINSZ: {x}\n", .{TIOCSWINSZ}); +// try stdout.print("TIOCSCTTY: {x}\n", .{TIOCSCTTY}); +// try stdout.print("TIOCPTYGRANT: {x}\n", .{TIOCPTYGRANT}); +// try stdout.print("TIOCPTYUNLK: {x}\n", .{TIOCPTYUNLK}); +// try stdout.print("TIOCPTYGNAME: {x}\n", .{TIOCPTYGNAME}); +// } diff --git a/gui/src-backend/pty/openpty.zig b/gui/src-backend/pty/openpty.zig new file mode 100644 index 00000000..ec1ed7fe --- /dev/null +++ b/gui/src-backend/pty/openpty.zig @@ -0,0 +1,83 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; + +const linux = std.os.linux; +const pi = @import("ioctl.zig"); + +const os_tag = builtin.os.tag; + +const grantpt = @import("grantpt.zig").grantpt; +const getpt = @import("getpt.zig").getpt; +const unlockpt = @import("unlockpt.zig").unlockpt; + +const ptsname = @import("ptsname.zig").ptsname; +const ptsname_max_size = @import("ptsname.zig").ptsname_max_size; + +const IoCtlError = pi.IoCtlError; + +pub const OpenPtyError = error{ + OpeningMasterFailed, + OpeningSlaveFailed, +} || std.fmt.BufPrintError || posix.OpenError || IoCtlError; + +pub fn openpty( + master: *posix.fd_t, + slave: *posix.fd_t, + name_buffer: ?[]u8, + name_len: ?*usize, + termios: ?*posix.termios, + winsize: ?*posix.winsize, +) OpenPtyError!void { + const master_fd = try getpt(); + errdefer _ = switch (os_tag) { + .linux => linux.close(master_fd), + else => std.c.close(master_fd), + }; + + try grantpt(master_fd); + try unlockpt(master_fd); + var buffer: [ptsname_max_size:0]u8 = undefined; + const name_slice = try ptsname(master_fd, &buffer); + buffer[name_slice.len] = 0; + + const slave_fd: posix.fd_t = switch (builtin.os.tag) { + .linux => @truncate( + @as(isize, @bitCast( + linux.ioctl(master_fd, pi.TIOCGPTPEER, pi.O_RDWR | pi.O_NOCTTY), + )), + ), + .macos => std.c.open(@ptrCast(name_slice.ptr), .{ .ACCMODE = .RDWR, .NOCTTY = true }), + else => {}, + }; + + if (slave_fd == -1) { + return OpenPtyError.OpeningSlaveFailed; + } + + if (termios) |term| + _ = posix.tcsetattr(slave_fd, .FLUSH, term.*) catch unreachable; + + if (winsize) |size| + _ = switch (builtin.os.tag) { + .linux => linux.ioctl(slave_fd, pi.TIOCSWINSZ, @intFromPtr(size)), + .macos => std.c.ioctl(slave_fd, pi.TIOCSWINSZ, @intFromPtr(size)), + else => @compileError("Unsupported os"), + }; + + master.* = master_fd; + slave.* = slave_fd; + + if (name_buffer) |nm_buf| { + std.mem.copyForwards(u8, @constCast(nm_buf), name_slice); + @memcpy(nm_buf[0..name_slice.len], name_slice[0..]); + nm_buf[name_slice.len] = 0; + } + if (name_len) |nm_len| { + nm_len.* = name_slice.len; + } +} diff --git a/gui/src-backend/pty/posix_openpt.zig b/gui/src-backend/pty/posix_openpt.zig new file mode 100644 index 00000000..e147d66d --- /dev/null +++ b/gui/src-backend/pty/posix_openpt.zig @@ -0,0 +1,17 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const linux = std.os.linux; + +const os_tag = builtin.os.tag; + +pub fn posix_openpt(flags: posix.O) posix.OpenError!posix.fd_t { + return if (os_tag == .linux) + @bitCast(@as(u32, @truncate(linux.open("/dev/ptmx", flags, 0)))) + else + @intCast(std.c.open("/dev/ptmx", flags)); +} diff --git a/gui/src-backend/pty/process.zig b/gui/src-backend/pty/process.zig new file mode 100644 index 00000000..129f24cf --- /dev/null +++ b/gui/src-backend/pty/process.zig @@ -0,0 +1,150 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const openpty_mod = @import("openpty.zig"); +const ioctl = @import("ioctl.zig"); + +pub const SpawnOptions = struct { + shell: []const u8, + cwd: []const u8, + cols: u16, + rows: u16, +}; + +pub const PtyProcess = struct { + master_fd: std.posix.fd_t, + pid: std.c.pid_t, + name: []const u8, + + pub fn write(self: *PtyProcess, data: []const u8) !usize { + if (data.len == 0) return 0; + const written = std.c.write(self.master_fd, data.ptr, data.len); + if (written < 0) return error.WriteFailed; + return @intCast(written); + } + + pub fn writeAll(self: *PtyProcess, data: []const u8) !void { + var offset: usize = 0; + while (offset < data.len) { + const written = try self.write(data[offset..]); + if (written == 0) return error.WriteFailed; + offset += written; + } + } + + pub fn read(self: *PtyProcess, buffer: []u8) !usize { + if (buffer.len == 0) return 0; + const n = std.c.read(self.master_fd, buffer.ptr, buffer.len); + if (n == 0) return error.EndOfStream; + if (n < 0) return error.ReadFailed; + return @intCast(n); + } + + pub fn resize(self: *PtyProcess, cols: u16, rows: u16) !void { + var ws: std.posix.winsize = .{ + .row = rows, + .col = cols, + .xpixel = 0, + .ypixel = 0, + }; + const rc = switch (builtin.os.tag) { + .linux => std.os.linux.ioctl(self.master_fd, ioctl.TIOCSWINSZ, @intFromPtr(&ws)), + .macos => std.c.ioctl(self.master_fd, ioctl.TIOCSWINSZ, @intFromPtr(&ws)), + else => @compileError("Unsupported PTY platform"), + }; + if (std.posix.errno(rc) != .SUCCESS) return error.ResizeFailed; + } + + pub fn close(self: *PtyProcess) void { + closeFd(self.master_fd); + } + + pub fn terminate(self: *PtyProcess) void { + _ = std.c.kill(self.pid, .HUP); + } + + pub fn wait(self: *PtyProcess) ?i64 { + var status: c_int = 0; + const rc = std.c.waitpid(self.pid, &status, 0); + if (rc <= 0) return null; + if ((status & 0x7f) == 0) return @intCast((status >> 8) & 0xff); + return null; + } +}; + +fn closeFd(fd: std.posix.fd_t) void { + _ = switch (builtin.os.tag) { + .linux => std.os.linux.close(fd), + else => std.c.close(fd), + }; +} + +fn dupTo(slave_fd: std.posix.fd_t, target: std.c.fd_t) void { + if (std.c.dup2(slave_fd, target) < 0) std.c.exit(126); +} + +fn allocPrintZ(allocator: std.mem.Allocator, comptime fmt: []const u8, args: anytype) ![:0]u8 { + const text = try std.fmt.allocPrint(allocator, fmt, args); + return try allocator.dupeZ(u8, text); +} + +pub fn spawnShell(allocator: std.mem.Allocator, options: SpawnOptions) !PtyProcess { + var master_fd: std.posix.fd_t = undefined; + var slave_fd: std.posix.fd_t = undefined; + var name_buf: [@import("ptsname.zig").ptsname_max_size]u8 = undefined; + var name_len: usize = 0; + var ws: std.posix.winsize = .{ + .row = options.rows, + .col = options.cols, + .xpixel = 0, + .ypixel = 0, + }; + + try openpty_mod.openpty(&master_fd, &slave_fd, &name_buf, &name_len, null, &ws); + errdefer closeFd(master_fd); + errdefer closeFd(slave_fd); + + const shell_z = try allocator.dupeZ(u8, options.shell); + const cwd_z = try allocator.dupeZ(u8, options.cwd); + const shell_env_z = try allocPrintZ(allocator, "SHELL={s}", .{options.shell}); + const cols_z = try allocPrintZ(allocator, "COLUMNS={d}", .{options.cols}); + const lines_z = try allocPrintZ(allocator, "LINES={d}", .{options.rows}); + + const argv = try allocator.alloc(?[*:0]const u8, 9); + argv[0] = "/usr/bin/env"; + argv[1] = "TERM=xterm-256color"; + argv[2] = "COLORTERM=truecolor"; + argv[3] = shell_env_z.ptr; + argv[4] = cols_z.ptr; + argv[5] = lines_z.ptr; + argv[6] = shell_z.ptr; + argv[7] = "-l"; + argv[8] = null; + + const pid = std.c.fork(); + if (pid < 0) return error.ForkFailed; + + if (pid == 0) { + closeFd(master_fd); + if (std.c.setsid() < 0) std.c.exit(126); + _ = switch (builtin.os.tag) { + .linux => std.os.linux.ioctl(slave_fd, ioctl.TIOCSCTTY, 0), + .macos => std.c.ioctl(slave_fd, ioctl.TIOCSCTTY, @as(c_int, 0)), + else => @compileError("Unsupported PTY platform"), + }; + dupTo(slave_fd, 0); + dupTo(slave_fd, 1); + dupTo(slave_fd, 2); + if (slave_fd > 2) closeFd(slave_fd); + if (std.c.chdir(cwd_z.ptr) < 0) {} + _ = std.c.execve("/usr/bin/env", @ptrCast(argv.ptr), std.c.environ); + _ = std.c.execve(shell_z.ptr, @ptrCast(argv[6..].ptr), std.c.environ); + std.c.exit(127); + } + + closeFd(slave_fd); + return .{ + .master_fd = master_fd, + .pid = pid, + .name = try allocator.dupe(u8, name_buf[0..name_len]), + }; +} diff --git a/gui/src-backend/pty/ptsname.zig b/gui/src-backend/pty/ptsname.zig new file mode 100644 index 00000000..4e0d5e09 --- /dev/null +++ b/gui/src-backend/pty/ptsname.zig @@ -0,0 +1,51 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const linux = std.os.linux; + +const pictl = @import("ioctl.zig"); + +const IoCtlError = pictl.IoCtlError; + +pub const ptsname_max_size = 128; + +pub fn ptsname(fd: posix.fd_t, buffer: [:0]u8) ![]const u8 { + var ptyno: u32 = 0; + var name: []const u8 = undefined; + + @memset(buffer, 0); + + switch (builtin.os.tag) { + .linux => { + const rc = linux.ioctl(fd, pictl.TIOCGPTN, @intFromPtr(&ptyno)); + try switch (posix.errno(rc)) { + .SUCCESS => {}, + .BADF => IoCtlError.InvalidFileDescriptor, + .FAULT => IoCtlError.InaccessibleMemory, + .INVAL => IoCtlError.BadRequistOrFlag, + .NOTTY => IoCtlError.NotTTY, + else => IoCtlError.Unexpcted, + }; + name = try std.fmt.bufPrintZ(buffer, "/dev/pts/{}", .{ptyno}); + }, + .macos => { + const rc = std.c.ioctl(fd, pictl.TIOCPTYGNAME, @intFromPtr(buffer.ptr)); + try switch (posix.errno(rc)) { + .SUCCESS => {}, + .BADF => IoCtlError.InvalidFileDescriptor, + .FAULT => IoCtlError.InaccessibleMemory, + .INVAL => IoCtlError.BadRequistOrFlag, + .NOTTY => IoCtlError.NotTTY, + else => IoCtlError.Unexpcted, + }; + name = std.mem.sliceTo(buffer.ptr, 0); + }, + else => {}, + } + + return name; +} diff --git a/gui/src-backend/pty/root.zig b/gui/src-backend/pty/root.zig new file mode 100644 index 00000000..3f538fb9 --- /dev/null +++ b/gui/src-backend/pty/root.zig @@ -0,0 +1,32 @@ +const std = @import("std"); + +pub const openpty = @import("openpty.zig").openpty; +pub const OpenPtyError = @import("openpty.zig").OpenPtyError; +pub const ptyname_max_len = @import("ptsname.zig").ptsname_max_size; + +pub const PtyProcess = @import("process.zig").PtyProcess; +pub const SpawnOptions = @import("process.zig").SpawnOptions; +pub const spawnShell = @import("process.zig").spawnShell; + +pub fn closeFd(fd: std.posix.fd_t) void { + _ = switch (@import("builtin").os.tag) { + .linux => std.os.linux.close(fd), + else => std.c.close(fd), + }; +} + +test "openpty returns fds and a device name" { + var master_fd: std.posix.fd_t = undefined; + var slave_fd: std.posix.fd_t = undefined; + var name: [ptyname_max_len]u8 = undefined; + var name_len: usize = undefined; + + try openpty(&master_fd, &slave_fd, &name, &name_len, null, null); + defer closeFd(master_fd); + defer closeFd(slave_fd); + + try std.testing.expect(master_fd >= 0); + try std.testing.expect(slave_fd >= 0); + try std.testing.expect(name_len > 0); + try std.testing.expect(std.mem.startsWith(u8, name[0..name_len], "/dev/")); +} diff --git a/gui/src-backend/pty/unlockpt.zig b/gui/src-backend/pty/unlockpt.zig new file mode 100644 index 00000000..7bba4469 --- /dev/null +++ b/gui/src-backend/pty/unlockpt.zig @@ -0,0 +1,31 @@ +// Adapted from PaNDa2code/zig_openpty (https://github.com/PaNDa2code/zig_openpty). +// Verified base commit: dbd62ff200cf9e1fee36d71175945016e0c54c6a. +// Vendored intentionally: Codegraff keeps PTY support dependency-free. + +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const linux = std.os.linux; + +const pictl = @import("ioctl.zig"); + +const IoCtlError = pictl.IoCtlError; + +pub fn unlockpt(fd: posix.fd_t) IoCtlError!void { + var unlock: u32 = 0; + + const rc = switch (builtin.os.tag) { + .linux => linux.ioctl(fd, pictl.TIOCSPTLCK, @intFromPtr(&unlock)), + .macos => std.c.ioctl(fd, pictl.TIOCPTYUNLK), + else => @compileError("Unsupported os"), + }; + + switch (posix.errno(rc)) { + .SUCCESS => {}, + .BADF => return IoCtlError.InvalidFileDescriptor, + .FAULT => return IoCtlError.InaccessibleMemory, + .INVAL => return IoCtlError.BadRequistOrFlag, + .NOTTY => return IoCtlError.NotTTY, + else => return IoCtlError.Unexpcted, + } +} diff --git a/gui/src-backend/runtime.zig b/gui/src-backend/runtime.zig new file mode 100644 index 00000000..6a8fb80c --- /dev/null +++ b/gui/src-backend/runtime.zig @@ -0,0 +1,5498 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const mer = @import("mer"); +const mer_runtime = @import("runtime"); +const pty = @import("pty"); + +const log = std.log.scoped(.backend); +const Value = std.json.Value; +const default_reasoning_effort = "medium"; +const session_ext = ".session.json"; +const terminal_scrollback_limit = 1024 * 1024; +const sse_event_replay_limit = 1000; +const session_scan_cache_ttl_ms = 10_000; +const runtime_status_cache_ttl_ms = 10_000; +const protocol_warning_limit_per_turn = 5; + +pub var instance: ?*Runtime = null; + +const Message = struct { + kind: Kind, + id: []const u8, + request_id: []const u8, + text: []const u8 = "", + name: []const u8 = "", + call_id: ?[]const u8 = null, + question: []const u8 = "", + summary: ?[]const u8 = null, + title: []const u8 = "", + subtitle: ?[]const u8 = null, + category: []const u8 = "info", + is_error: bool = false, + error_message: []const u8 = "", + tool_detail_json: ?[]const u8 = null, + result_detail_json: ?[]const u8 = null, + + const Kind = enum { + user, + context_compacted, + assistant, + reasoning, + status, + status_output, + tool_start, + tool_end, + @"error", + }; +}; + +const Conversation = struct { + workspace_path: []const u8, + conversation_id: []const u8, + session_name: []const u8, + title: []const u8, + messages: std.ArrayList(Message) = .empty, + active_request_ids: std.ArrayList([]const u8) = .empty, + active_agent_id: ?[]const u8 = null, + plan_mode: bool = false, + goal: ?[]const u8 = null, + updated_at: i64 = 0, + followup: ?Followup = null, + session_provider: ?[]const u8 = null, + session_model: ?[]const u8 = null, + session_strict: bool = false, + session_ultracode_mode: bool = false, + cli_messages_json: ?[]const u8 = null, +}; + +const FollowupOption = struct { + id: []const u8, + label: []const u8, +}; + +const Followup = struct { + followup_id: []const u8, + workspace_path: []const u8, + conversation_id: []const u8, + request_id: []const u8, + kind: []const u8 = "text", + question: []const u8, + options: []const FollowupOption = &.{}, + call_id: ?[]const u8, +}; + +const Workspace = struct { + path: []const u8, + kind: []const u8 = "project", + display_name: ?[]const u8 = null, +}; + +const SavedWorkspace = struct { + id: []const u8, + name: []const u8, + layout_json: []const u8, + updated_at: i64, +}; + +const PromptSettings = struct { + selected_provider: ?[]const u8 = null, + selected_model: ?[]const u8 = null, + selected_effort: ?[]const u8 = null, + fast_enabled: bool = false, +}; + +const ModelUsage = struct { + prompt_count: u64 = 0, + last_used_at: i64 = 0, +}; + +const TerminalSessionState = struct { + terminal_id: []const u8, + instance_id: []const u8, + workspace_path: []const u8, + cwd: []const u8, + shell: []const u8, + cols: u16, + rows: u16, + proc: pty.PtyProcess, + scrollback: std.ArrayList(u8) = .empty, + scrollback_truncated: bool = false, + output_seq: u64 = 0, + utf8_pending: [4]u8 = undefined, + utf8_pending_len: usize = 0, + io_mutex: std.Io.Mutex = .init, + closing: std.atomic.Value(bool) = .init(false), + exited: std.atomic.Value(bool) = .init(false), +}; + +const GraffSession = struct { + conversation_id: []const u8, + workspace_path: []const u8, + session_name: []const u8, + desired_provider: ?[]const u8 = null, + desired_model: ?[]const u8 = null, + acked_provider: ?[]const u8 = null, + acked_model: ?[]const u8 = null, + acked_effort: ?[]const u8 = null, + acked_fast: ?bool = null, + acked_agent: ?[]const u8 = null, + acked_mode: ?[]const u8 = null, + child: *std.process.Child, + yolo_enabled: bool = false, + stdin_mutex: std.Io.Mutex = .init, +}; + +const SseEvent = struct { + seq: u64, + name: []const u8, + data: []const u8, +}; + +const RuntimeStatusCacheEntry = struct { + json: []const u8, + expires_ms: i64, +}; + +const SessionScanCacheEntry = struct { + scanned_ms: i64, +}; + +pub const Runtime = struct { + allocator: std.mem.Allocator, + arena_state: std.heap.ArenaAllocator, + arena: std.mem.Allocator, + mutex: std.Io.Mutex = .init, + version: std.atomic.Value(u64) = .init(1), + workspaces: std.ArrayList(Workspace) = .empty, + saved_workspaces: std.ArrayList(SavedWorkspace) = .empty, + conversations: std.StringHashMap(*Conversation), + selected_by_workspace: std.StringHashMap([]const u8), + terminals: std.StringHashMap(*TerminalSessionState), + active_children: std.StringHashMap(*std.process.Child), + graff_sessions: std.StringHashMap(*GraffSession), + runtime_status_cache: std.StringHashMap(RuntimeStatusCacheEntry), + runtime_status_generation: u64 = 0, + session_scan_cache: std.StringHashMap(SessionScanCacheEntry), + model_usage: std.StringHashMap(ModelUsage), + event_mutex: std.Io.Mutex = .init, + events: std.ArrayList(SseEvent) = .empty, + next_event_seq: u64 = 1, + active_workspace_path: ?[]const u8 = null, + active_conversation_id: ?[]const u8 = null, + active_agent_id: []const u8 = "forge", + gui_state_loaded: bool = false, + settings_loaded: bool = false, + settings: PromptSettings = .{}, + schema_loaded: bool = false, + schema_raw: ?[]const u8 = null, + + pub fn init(allocator: std.mem.Allocator) !*Runtime { + const rt = try allocator.create(Runtime); + rt.* = .{ + .allocator = allocator, + .arena_state = .init(allocator), + .arena = undefined, + .conversations = std.StringHashMap(*Conversation).init(allocator), + .selected_by_workspace = std.StringHashMap([]const u8).init(allocator), + .terminals = std.StringHashMap(*TerminalSessionState).init(allocator), + .active_children = std.StringHashMap(*std.process.Child).init(allocator), + .graff_sessions = std.StringHashMap(*GraffSession).init(allocator), + .runtime_status_cache = std.StringHashMap(RuntimeStatusCacheEntry).init(allocator), + .session_scan_cache = std.StringHashMap(SessionScanCacheEntry).init(allocator), + .model_usage = std.StringHashMap(ModelUsage).init(allocator), + }; + rt.arena = rt.arena_state.allocator(); + instance = rt; + log.info("backend runtime initialized", .{}); + return rt; + } + + pub fn deinit(self: *Runtime) void { + var it = self.terminals.valueIterator(); + while (it.next()) |session| closeTerminalSession(session.*); + self.terminals.deinit(); + var graff_it = self.graff_sessions.valueIterator(); + while (graff_it.next()) |session| self.closeGraffSession(session.*); + self.graff_sessions.deinit(); + self.active_children.deinit(); + var cache_it = self.runtime_status_cache.iterator(); + while (cache_it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.json); + } + self.runtime_status_cache.deinit(); + var usage_it = self.model_usage.keyIterator(); + while (usage_it.next()) |key| self.allocator.free(key.*); + self.model_usage.deinit(); + var scan_it = self.session_scan_cache.iterator(); + while (scan_it.next()) |entry| self.allocator.free(entry.key_ptr.*); + self.session_scan_cache.deinit(); + for (self.events.items) |event| self.freeSseEvent(event); + self.events.deinit(self.allocator); + self.conversations.deinit(); + self.selected_by_workspace.deinit(); + self.arena_state.deinit(); + self.allocator.destroy(self); + instance = null; + } + + pub fn handleApi(self: *Runtime, req: mer.Request) mer.Response { + if (req.method != .POST) return mer.Response.init(.method_not_allowed, .json, "{\"error\":\"method not allowed\"}"); + self.ensureGuiStateLoaded(); + const cmd = commandName(req.path); + + if (std.mem.eql(u8, cmd, "get_session_snapshot")) { + const root = parse(req) catch null; + const force_session_scan = if (root) |value| boolField(value, "forceSessionScan") orelse false else false; + self.mutex.lockUncancelable(mer_runtime.io); + self.refreshWorkspaceSessionsLocked(force_session_scan); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + if (std.mem.eql(u8, cmd, "open_workspace")) return self.openWorkspace(req); + if (std.mem.eql(u8, cmd, "get_runtime_status")) return self.getRuntimeStatus(req); + if (std.mem.eql(u8, cmd, "get_prompt_settings")) return self.getPromptSettings(req); + if (std.mem.eql(u8, cmd, "update_prompt_settings")) return self.updatePromptSettings(req); + if (std.mem.eql(u8, cmd, "get_theme_settings")) return self.getThemeSettings(req); + if (std.mem.eql(u8, cmd, "update_theme_settings")) return self.updateThemeSettings(req); + if (std.mem.eql(u8, cmd, "list_providers")) return self.listProviders(req); + if (std.mem.eql(u8, cmd, "list_commands")) return mer.json(commands_json); + if (std.mem.eql(u8, cmd, "select_conversation") or std.mem.eql(u8, cmd, "ensure_conversation_view")) return self.selectConversation(req); + if (std.mem.eql(u8, cmd, "start_new_chat")) return self.startNewChat(req); + if (std.mem.eql(u8, cmd, "create_managed_chat")) return self.createManagedChat(req); + if (std.mem.eql(u8, cmd, "handoff_chat")) return self.handoffChat(req); + if (std.mem.eql(u8, cmd, "send_prompt")) return self.sendPrompt(req); + if (std.mem.eql(u8, cmd, "stop_prompt")) return self.stopPrompt(req); + if (std.mem.eql(u8, cmd, "compact_conversation")) return self.compactConversation(req); + if (std.mem.eql(u8, cmd, "respond_followup")) return self.respondFollowup(req); + if (std.mem.eql(u8, cmd, "archive_conversation")) return self.archiveConversation(req); + if (std.mem.eql(u8, cmd, "archive_workspace")) return self.archiveWorkspace(req); + if (std.mem.eql(u8, cmd, "rename_workspace")) return self.renameWorkspace(req); + if (std.mem.eql(u8, cmd, "run_slash_command")) return self.runSlashCommand(req); + if (std.mem.eql(u8, cmd, "workspace_sync")) return self.workspaceSync(req); + if (std.mem.eql(u8, cmd, "workspace_query")) return self.workspaceQuery(req); + if (std.mem.eql(u8, cmd, "build_workflow_draft")) return self.buildWorkflowDraft(req); + if (std.mem.eql(u8, cmd, "export_workflow_draft")) return self.exportWorkflowDraft(req); + if (std.mem.eql(u8, cmd, "set_active_agent")) return self.setActiveAgent(req); + if (std.mem.eql(u8, cmd, "set_effort")) return self.setEffort(req); + if (std.mem.eql(u8, cmd, "set_fast")) return self.setFast(req); + if (std.mem.eql(u8, cmd, "list_mcp_servers") or std.mem.eql(u8, cmd, "reload_mcp_servers")) return self.listMcpServers(req); + if (std.mem.eql(u8, cmd, "import_mcp_config")) return self.importMcpConfig(req); + if (std.mem.eql(u8, cmd, "remove_mcp_server")) return self.removeMcpServer(req); + if (std.mem.eql(u8, cmd, "login_mcp_server") or std.mem.eql(u8, cmd, "logout_mcp_server")) return mer.Response.init(.bad_request, .json, "{\"error\":\"graff runs stdio MCP servers only; OAuth login/logout does not apply\"}"); + if (std.mem.eql(u8, cmd, "start_provider_auth")) return self.startProviderAuth(req); + if (std.mem.eql(u8, cmd, "complete_provider_auth")) return self.completeProviderAuth(req); + if (std.mem.eql(u8, cmd, "remove_provider")) return self.removeProvider(req); + if (std.mem.eql(u8, cmd, "read_workspace_file")) return self.readWorkspaceFile(req); + if (std.mem.eql(u8, cmd, "save_pasted_image")) return self.savePastedImage(req); + if (std.mem.eql(u8, cmd, "save_attachment_file")) return self.saveAttachmentFile(req); + if (std.mem.eql(u8, cmd, "image_thumbnail")) return mer.Response.init(.bad_request, .json, "{\"error\":\"image thumbnails are not supported by the Zig backend yet\"}"); + if (std.mem.eql(u8, cmd, "terminal_open")) return self.terminalOpen(req); + if (std.mem.eql(u8, cmd, "terminal_write")) return self.terminalWrite(req); + if (std.mem.eql(u8, cmd, "terminal_resize")) return self.terminalResize(req); + if (std.mem.eql(u8, cmd, "terminal_close")) return self.terminalClose(req); + if (std.mem.eql(u8, cmd, "save_conversation_layout")) return self.saveConversationLayout(req); + if (std.mem.eql(u8, cmd, "get_conversation_layout")) return mer.json("null"); + if (std.mem.eql(u8, cmd, "create_saved_workspace")) return self.createSavedWorkspace(req); + if (std.mem.eql(u8, cmd, "update_saved_workspace_layout")) return self.updateSavedWorkspaceLayout(req); + if (std.mem.eql(u8, cmd, "get_saved_workspace")) return self.getSavedWorkspace(req); + if (std.mem.eql(u8, cmd, "rename_saved_workspace") or std.mem.eql(u8, cmd, "delete_saved_workspace")) return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + if (std.mem.eql(u8, cmd, "checkout_git_branch") or std.mem.eql(u8, cmd, "create_git_branch") or std.mem.eql(u8, cmd, "commit_git_changes") or std.mem.eql(u8, cmd, "push_git_branch")) return self.gitMutation(req, cmd); + if (std.mem.eql(u8, cmd, "clone_repository") or std.mem.eql(u8, cmd, "quick_start_project")) return mer.Response.init(.bad_request, .json, "{\"error\":\"repository creation is not implemented in the Zig backend yet\"}"); + + return mer.Response.init(.not_found, .json, "{\"error\":\"unknown api command\"}"); + } + + fn jsonResponse(_: *Runtime, _: mer.Request, body: []const u8) mer.Response { + return mer.Response.init(.ok, .json, body); + } + + fn openWorkspace(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const path = stringField(root, "path") orelse return bad(req, "missing path"); + self.mutex.lockUncancelable(mer_runtime.io); + const owned = self.dupe(path); + self.activateWorkspaceLocked(owned); + self.scanWorkspaceSessionsLocked(owned, true); + self.ensureWorkspaceSelectionLocked(owned); + self.active_conversation_id = self.selected_by_workspace.get(owned); + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn getRuntimeStatus(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch null; + const requested = if (root) |v| stringField(v, "workspacePath") else null; + self.mutex.lockUncancelable(mer_runtime.io); + const path = requested orelse self.active_workspace_path; + self.mutex.unlock(mer_runtime.io); + return self.runtimeStatusJson(req.allocator, path) catch oom(); + } + + fn getPromptSettings(self: *Runtime, req: mer.Request) mer.Response { + self.ensurePromptSettingsLoaded(); + return self.jsonResponse(req, self.promptSettingsJson(req.allocator) catch return oom()); + } + + fn updatePromptSettings(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + self.ensurePromptSettingsLoaded(); + self.mutex.lockUncancelable(mer_runtime.io); + if (stringField(input, "providerId")) |v| self.settings.selected_provider = self.dupe(v); + if (stringField(input, "modelId")) |v| self.settings.selected_model = self.dupe(v); + if (input == .object) { + if (input.object.get("reasoningEffort")) |v| switch (v) { + .string => |s| self.settings.selected_effort = if (validReasoningEffort(s)) self.dupe(s) else null, + .null => self.settings.selected_effort = null, + else => {}, + }; + } + self.bumpLocked(); + self.savePromptSettingsLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.promptSettingsJson(req.allocator) catch return oom()); + } + + fn getThemeSettings(_: *Runtime, req: mer.Request) mer.Response { + return mer.json(readThemeSettings(req.allocator) catch "{\"mode\":null,\"preset\":null}"); + } + + fn updateThemeSettings(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const mode = stringField(input, "mode") orelse return bad(req, "missing mode"); + const preset = stringField(input, "preset") orelse return bad(req, "missing preset"); + if (!validThemeMode(mode) or !validThemePreset(preset)) return bad(req, "invalid theme settings"); + writeThemeSettings(req.allocator, mode, preset) catch return bad(req, "failed to persist theme settings"); + return mer.json(readThemeSettings(req.allocator) catch "{\"mode\":null,\"preset\":null}"); + } + + fn listProviders(self: *Runtime, req: mer.Request) mer.Response { + const schema = self.cachedSchemaValue(req.allocator); + var out: std.Io.Writer.Allocating = .init(req.allocator); + const w = &out.writer; + if (!writeProviderSummariesFromSchema(w, schema)) { + writeFallbackProviderSummaries(w) catch return oom(); + } + return self.jsonResponse(req, out.written()); + } + + fn selectConversation(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse return bad(req, "missing workspacePath"); + const conv_id = stringField(root, "conversationId") orelse return bad(req, "missing conversationId"); + self.mutex.lockUncancelable(mer_runtime.io); + const wpath = self.dupe(workspace); + if (self.conversations.get(conv_id)) |conversation| { + if (!std.mem.eql(u8, conversation.workspace_path, wpath)) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "conversation does not belong to workspace"); + } + } + self.setActiveWorkspaceLocked(wpath); + if (self.conversations.get(conv_id)) |conversation| { + self.active_conversation_id = conversation.conversation_id; + self.selected_by_workspace.put(wpath, conversation.conversation_id) catch {}; + } + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn startNewChat(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse return bad(req, "missing workspacePath"); + self.mutex.lockUncancelable(mer_runtime.io); + const cid = self.uniqueId("chat"); + const conversation = self.createConversationLocked(workspace, cid, "New chat"); + self.active_conversation_id = conversation.conversation_id; + self.selected_by_workspace.put(conversation.workspace_path, conversation.conversation_id) catch {}; + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn handoffChat(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse return bad(req, "missing input"); + const source_workspace = stringField(input, "sourceWorkspacePath") orelse return bad(req, "missing sourceWorkspacePath"); + const target = stringField(input, "target") orelse return bad(req, "missing target"); + const branch_name = stringField(input, "branchName"); + const source_conversation_id = stringField(input, "conversationId"); + + const target_workspace = if (std.mem.eql(u8, target, "local")) blk: { + const git = gitRuntimeStatus(req.allocator, source_workspace); + const local_workspace = git.root_path orelse source_workspace; + if (branch_name) |branch| { + _ = gitOutput(req.allocator, local_workspace, &.{ "git", "checkout", "-B", branch }) catch return bad(req, "failed to switch local branch"); + } + break :blk local_workspace; + } else if (std.mem.eql(u8, target, "worktree")) blk: { + const branch = branch_name orelse return bad(req, "missing branchName"); + const git = gitRuntimeStatus(req.allocator, source_workspace); + const main_workspace = git.root_path orelse source_workspace; + const worktree_path = worktreePathForBranch(req.allocator, main_workspace, branch) catch return oom(); + if (!fileExists(worktree_path)) { + _ = gitOutput(req.allocator, main_workspace, &.{ "git", "worktree", "add", "-B", branch, worktree_path }) catch return bad(req, "failed to create git worktree"); + } + break :blk worktree_path; + } else return bad(req, "invalid handoff target"); + + self.mutex.lockUncancelable(mer_runtime.io); + const owned_workspace = self.dupe(target_workspace); + self.invalidateRuntimeStatusLocked(source_workspace); + self.invalidateRuntimeStatusLocked(owned_workspace); + self.activateWorkspaceLocked(owned_workspace); + const conversation = if (source_conversation_id) |cid| conv: { + if (self.conversations.get(cid)) |existing| { + self.dropGraffSession(cid); + existing.workspace_path = owned_workspace; + existing.updated_at = nowMillis(); + break :conv existing; + } + break :conv self.createConversationLocked(owned_workspace, cid, "New chat"); + } else conv: { + const cid = self.uniqueId("chat"); + break :conv self.createConversationLocked(owned_workspace, cid, "New chat"); + }; + self.active_conversation_id = conversation.conversation_id; + self.selected_by_workspace.put(conversation.workspace_path, conversation.conversation_id) catch {}; + self.writeConversationSessionFileLocked(conversation); + self.invalidateSessionScanLocked(owned_workspace); + self.scanWorkspaceSessionsLocked(owned_workspace, true); + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn createManagedChat(self: *Runtime, req: mer.Request) mer.Response { + const path = self.createManagedChatPath(); + ensureDirectory(req.allocator, path) catch return bad(req, "failed to create managed chat workspace"); + self.mutex.lockUncancelable(mer_runtime.io); + const owned = self.dupe(path); + self.activateWorkspaceLocked(owned); + if (self.workspaceIndexLocked(owned)) |idx| { + self.workspaces.items[idx].kind = "managed_chat"; + self.workspaces.items[idx].display_name = "New chat"; + } + const cid = self.uniqueId("chat"); + const conversation = self.createConversationLocked(owned, cid, "New chat"); + self.active_conversation_id = conversation.conversation_id; + self.selected_by_workspace.put(conversation.workspace_path, conversation.conversation_id) catch {}; + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn sendPrompt(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse return bad(req, "missing input"); + const workspace = stringField(input, "workspacePath") orelse return bad(req, "missing workspacePath"); + const prompt = stringField(input, "prompt") orelse return bad(req, "missing prompt"); + const provided_conversation = stringField(input, "conversationId"); + const agent_id = stringField(input, "agentId"); + self.ensurePromptSettingsLoaded(); + + if (!fileExists(workspace)) { + if (isGeneratedManagedChatPath(req.allocator, workspace)) { + ensureDirectory(req.allocator, workspace) catch return bad(req, "failed to create managed chat workspace"); + } else { + return bad(req, "workspace path does not exist"); + } + } + + const request_id = self.uniqueId("request"); + var conversation_id: []const u8 = undefined; + var session_name: []const u8 = undefined; + var workspace_for_thread: []const u8 = undefined; + var agent_for_thread: []const u8 = undefined; + var plan_mode_for_thread = false; + + self.mutex.lockUncancelable(mer_runtime.io); + { + const cid = provided_conversation orelse self.uniqueId("chat"); + const existing = self.conversations.get(cid); + if (existing) |conv| { + if (!std.mem.eql(u8, conv.workspace_path, workspace)) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "conversation does not belong to workspace"); + } + } + const conv = existing orelse self.createConversationLocked(workspace, cid, titleFromPrompt(self.arena, prompt)); + if (conv.active_request_ids.items.len > 0) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "conversation already has an active prompt"); + } + conversation_id = conv.conversation_id; + session_name = conv.session_name; + workspace_for_thread = conv.workspace_path; + self.setActiveWorkspaceLocked(conv.workspace_path); + self.active_conversation_id = conv.conversation_id; + self.selected_by_workspace.put(conv.workspace_path, conv.conversation_id) catch {}; + self.ensureConversationSessionFileLocked(conv); + if (shouldAutoTitleConversation(conv)) { + conv.title = titleFromPrompt(self.arena, prompt); + self.renameManagedChatWorkspaceLocked(conv.workspace_path, conv.title); + } + const effective_agent = agent_id orelse conv.active_agent_id orelse self.active_agent_id; + agent_for_thread = self.dupe(effective_agent); + plan_mode_for_thread = guiAgentReadOnly(effective_agent); + conv.plan_mode = plan_mode_for_thread; + conv.active_agent_id = agent_for_thread; + conv.updated_at = nowMillis(); + conv.messages.append(self.arena, .{ + .kind = .user, + .id = self.fmt("{s}-user", .{request_id}), + .request_id = request_id, + .text = self.dupe(prompt), + }) catch {}; + conv.active_request_ids.append(self.arena, request_id) catch {}; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + self.saveGuiStateLocked(); + } + self.mutex.unlock(mer_runtime.io); + + const prompt_owned = self.dupe(prompt); + const thread = std.Thread.spawn(.{}, graffTurnThread, .{ self, conversation_id, request_id, session_name, workspace_for_thread, prompt_owned, agent_for_thread, plan_mode_for_thread }) catch |err| { + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(conversation_id)) |conv| { + removeString(&conv.active_request_ids, request_id); + conv.messages.append(self.arena, .{ + .kind = .@"error", + .id = self.fmt("{s}-error", .{request_id}), + .request_id = request_id, + .error_message = self.fmt("Failed to start graff thread: {s}", .{@errorName(err)}), + }) catch {}; + self.writeConversationSessionFileLocked(conv); + self.emitRequestEventLocked("request-finished", conv, request_id); + } + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + }; + thread.detach(); + + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn stopPrompt(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const cid = stringField(input, "conversationId") orelse return mer.json("{}"); + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + for (conv.active_request_ids.items) |rid| { + if (self.active_children.get(rid)) |child| { + child.kill(mer_runtime.io); + _ = self.active_children.remove(rid); + } + self.emitRequestEventLocked("request-cancelled", conv, rid); + } + self.dropGraffSession(cid); + conv.active_request_ids.clearRetainingCapacity(); + conv.followup = null; + self.writeConversationSessionFileLocked(conv); + } + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return mer.json("{}"); + } + + fn compactConversation(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const cid = stringField(input, "conversationId") orelse return bad(req, "missing conversationId"); + self.performConversationCompaction(req.allocator, cid) catch |err| { + return bad(req, switch (err) { + error.ConversationNotFound => "conversation not found", + error.CompactFailed => "compaction failed", + else => "failed to compact conversation", + }); + }; + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn performConversationCompaction(self: *Runtime, alloc: std.mem.Allocator, cid: []const u8) !void { + var workspace: []const u8 = undefined; + var session_name: []const u8 = undefined; + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (conv.active_request_ids.items.len > 0) { + self.mutex.unlock(mer_runtime.io); + return error.CompactFailed; + } + workspace = self.dupe(conv.workspace_path); + session_name = self.dupe(conv.session_name); + self.writeConversationSessionFileLocked(conv); + self.dropGraffSession(cid); + } else { + self.mutex.unlock(mer_runtime.io); + return error.ConversationNotFound; + } + self.mutex.unlock(mer_runtime.io); + + try self.runGraffCompact(alloc, workspace, session_name); + + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + const path = sessionFilePath(self.allocator, workspace, session_name) catch null; + if (path) |p| { + self.importSessionFileLocked(workspace, session_name, p); + self.allocator.free(p); + } + const rid = self.uniqueId("compact"); + conv.messages.append(self.arena, .{ + .kind = .context_compacted, + .id = rid, + .request_id = rid, + .text = "Conversation compacted. Future turns resume the compacted graff session.", + }) catch {}; + conv.updated_at = nowMillis(); + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + self.mutex.unlock(mer_runtime.io); + } + + fn runGraffCompact(self: *Runtime, alloc: std.mem.Allocator, workspace: []const u8, session_name: []const u8) !void { + if (session_name.len == 0 or std.mem.indexOfAny(u8, session_name, "/\\") != null) return error.CompactFailed; + const bin = self.codegraffBinary(); + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(alloc); + try argv.append(alloc, "/usr/bin/env"); + try argv.append(alloc, try std.fmt.allocPrint(alloc, "HOME={s}", .{homeDir()})); + try argv.append(alloc, try std.fmt.allocPrint(alloc, "PATH={s}/bin:{s}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", .{ homeDir(), homeDir() })); + try argv.append(alloc, bin); + try argv.append(alloc, "--json"); + try argv.append(alloc, "--resume"); + try argv.append(alloc, session_name); + + var child = try std.process.spawn(mer_runtime.io, .{ + .argv = argv.items, + .cwd = .{ .path = workspace }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .ignore, + }); + defer child.kill(mer_runtime.io); + + { + var wbuf: [1024]u8 = undefined; + var cw = child.stdin.?.writerStreaming(mer_runtime.io, &wbuf); + try cw.interface.writeAll("{\"type\":\"compact\"}\n"); + try cw.interface.flush(); + child.stdin.?.close(mer_runtime.io); + child.stdin = null; + } + + var rbuf: [16 * 1024]u8 = undefined; + var rdr = child.stdout.?.readerStreaming(mer_runtime.io, &rbuf); + var saw_ok = false; + while (true) { + const ev_line = rdr.interface.takeDelimiter('\n') catch break orelse break; + const line = std.mem.trim(u8, ev_line, " \t\r\n"); + if (line.len == 0) continue; + var tmp = std.heap.ArenaAllocator.init(alloc); + defer tmp.deinit(); + const event = std.json.parseFromSliceLeaky(Value, tmp.allocator(), line, .{}) catch continue; + const ty = stringField(event, "type") orelse continue; + if (std.mem.eql(u8, ty, "compact")) { + if (boolField(event, "ok") orelse false) saw_ok = true; + } else if (std.mem.eql(u8, ty, "error")) { + return error.CompactFailed; + } + } + const term = child.wait(mer_runtime.io) catch return error.CompactFailed; + if (!saw_ok or term != .exited or term.exited != 0) return error.CompactFailed; + } + + fn respondFollowup(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const response = objectField(root, "response") orelse root; + const followup_id = stringField(response, "followupId") orelse return bad(req, "missing followupId"); + const cancelled = boolField(response, "cancelled") orelse false; + const notes = stringField(response, "text") orelse ""; + const selected_ids = arrayField(response, "selectedOptionIds"); + + var line: ?[]const u8 = null; + var write_failed = false; + self.mutex.lockUncancelable(mer_runtime.io); + var target_child: ?*std.process.Child = null; + var it = self.conversations.iterator(); + while (it.next()) |entry| { + const conv = entry.value_ptr.*; + const f = conv.followup orelse continue; + if (!std.mem.eql(u8, f.followup_id, followup_id)) continue; + + const rid = f.request_id; + if (cancelled) { + if (self.active_children.get(rid)) |child| { + child.kill(mer_runtime.io); + _ = self.active_children.remove(rid); + } + self.dropGraffSession(conv.conversation_id); + removeString(&conv.active_request_ids, rid); + conv.followup = null; + self.writeConversationSessionFileLocked(conv); + self.emitRequestEventLocked("request-cancelled", conv, rid); + } else { + target_child = self.active_children.get(rid); + line = answerLineJson(req.allocator, f, notes, selected_ids, false) catch null; + conv.followup = null; + self.writeConversationSessionFileLocked(conv); + } + break; + } + if (!cancelled) if (line) |answer_line| { + if (target_child) |child| { + writeAnswerLine(child, answer_line) catch { + write_failed = true; + }; + } else write_failed = true; + }; + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + + if (write_failed) return bad(req, "followup request is no longer active"); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn archiveConversation(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const cid = stringField(root, "conversationId") orelse return bad(req, "missing conversationId"); + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + self.dropGraffSession(cid); + self.deleteConversationSessionFileLocked(conv); + _ = self.conversations.remove(cid); + if (self.active_conversation_id != null and std.mem.eql(u8, self.active_conversation_id.?, conv.conversation_id)) self.active_conversation_id = null; + } + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn archiveWorkspace(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse return bad(req, "missing workspacePath"); + self.mutex.lockUncancelable(mer_runtime.io); + if (self.workspaceIndexLocked(workspace)) |idx| _ = self.workspaces.swapRemove(idx); + _ = self.selected_by_workspace.remove(workspace); + if (self.active_workspace_path != null and std.mem.eql(u8, self.active_workspace_path.?, workspace)) { + self.active_workspace_path = null; + self.active_conversation_id = null; + } + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn renameWorkspace(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse return bad(req, "missing workspacePath"); + self.mutex.lockUncancelable(mer_runtime.io); + if (self.workspaceIndexLocked(workspace)) |idx| { + self.workspaces.items[idx].display_name = if (stringField(root, "displayName")) |name| self.dupe(name) else null; + } + self.bumpLocked(); + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.jsonResponse(req, self.snapshotJson(req.allocator) catch return oom()); + } + + fn runSlashCommand(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const name = stringField(root, "name") orelse "help"; + const args_text = joinArgs(req.allocator, root) catch return oom(); + if (std.mem.eql(u8, name, "help")) return mer.json("{\"title\":\"/help\",\"body\":\"Available commands: /help, /agent, /goal, /loop, /bash , /compact, /workspace-status, /workspace-info, /workspace-query , /workflow , /reasoning-effort , /mcp.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"text\",\"payload\":null}"); + if (std.mem.eql(u8, name, "agent")) return self.agentCommand(req); + if (std.mem.eql(u8, name, "bash")) { + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":") catch return oom(); + writeString(&out.writer, if (args_text.len > 0) self.fmt("/bash {s}", .{args_text}) else "/bash") catch return oom(); + out.writer.writeAll(",\"body\":") catch return oom(); + writeString(&out.writer, if (args_text.len > 0) self.fmt("Zig backend did not execute shell command yet:\\n\\n$ {s}", .{args_text}) else "usage: /bash ") catch return oom(); + out.writer.writeAll(",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"text\",\"payload\":null}") catch return oom(); + return mer.json(out.written()); + } + if (std.mem.eql(u8, name, "goal")) return self.goalCommand(req, root, args_text); + if (std.mem.eql(u8, name, "loop")) return self.loopCommand(req, root, args_text); + if (std.mem.eql(u8, name, "compact")) { + const cid = stringField(root, "conversationId") orelse return bad(req, "missing conversationId"); + self.performConversationCompaction(req.allocator, cid) catch |err| { + return commandText(req, "/compact", switch (err) { + error.ConversationNotFound => "Conversation not found.", + error.CompactFailed => "Compaction failed; history was left unchanged.", + else => "Failed to compact conversation.", + }); + }; + const snap = self.snapshotJson(req.allocator) catch return oom(); + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/compact\",\"body\":\"Conversation compacted. Future turns resume the compacted session.\",\"snapshot\":") catch return oom(); + out.writer.writeAll(snap) catch return oom(); + out.writer.writeAll(",\"savedPath\":null,\"resultKind\":\"snapshot\",\"payload\":null}") catch return oom(); + return mer.json(out.written()); + } + if (std.mem.eql(u8, name, "reasoning-effort") or std.mem.eql(u8, name, "effort") or std.mem.eql(u8, name, "reasoning")) return self.reasoningEffortCommand(req, args_text); + if (std.mem.eql(u8, name, "workflow")) return self.workflowCommand(req, args_text); + if (std.mem.eql(u8, name, "workspace-query") or std.mem.eql(u8, name, "workspace-search")) return self.workspaceQueryCommand(req, root, args_text); + if (std.mem.eql(u8, name, "workspace-info")) return self.workspaceInfoCommand(req, root); + if (std.mem.eql(u8, name, "mcp")) return self.mcpCommand(req, root); + if (std.mem.eql(u8, name, "workspace-status")) return self.workspaceStatusCommand(req, root); + return commandText(req, "Command failed", self.fmt("Unknown command: /{s}. Run /help for the list.", .{name})); + } + + fn goalCommand(self: *Runtime, req: mer.Request, root: Value, args_text: []const u8) mer.Response { + const cid = stringField(root, "conversationId"); + self.mutex.lockUncancelable(mer_runtime.io); + if (cid) |conversation_id| { + if (self.conversations.get(conversation_id)) |conv| { + if (args_text.len == 0) { + const body = if (conv.goal) |goal| self.fmt("Current goal: {s}\n\nClear it with /goal clear.", .{goal}) else "No active goal. Set one with /goal ."; + self.mutex.unlock(mer_runtime.io); + return commandText(req, "/goal", body); + } + if (std.mem.eql(u8, args_text, "clear") or std.mem.eql(u8, args_text, "off")) { + conv.goal = null; + conv.updated_at = nowMillis(); + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return commandText(req, "/goal", "Goal cleared. Future turns will not get goal steering."); + } + conv.goal = self.dupe(args_text); + conv.updated_at = nowMillis(); + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + const body = self.fmt("Goal set: {s}", .{args_text}); + self.mutex.unlock(mer_runtime.io); + return commandText(req, "/goal", body); + } + } + self.mutex.unlock(mer_runtime.io); + return commandText(req, "/goal", "No active conversation. Open a chat before setting a goal."); + } + + fn loopCommand(self: *Runtime, req: mer.Request, root: Value, args_text: []const u8) mer.Response { + const workspace = stringField(root, "workspacePath") orelse self.active_workspace_path orelse ""; + if (workspace.len == 0) return commandText(req, "/loop", "Open a workspace before running /loop."); + var prompt = args_text; + if (prompt.len == 0) prompt = "Run an autonomous plan-act-verify pass."; + const request_id = self.uniqueId("loop"); + self.mutex.lockUncancelable(mer_runtime.io); + const cid = stringField(root, "conversationId") orelse self.uniqueId("chat"); + const conv = self.conversations.get(cid) orelse self.createConversationLocked(workspace, cid, titleFromPrompt(self.arena, prompt)); + self.setActiveWorkspaceLocked(conv.workspace_path); + self.active_conversation_id = conv.conversation_id; + self.selected_by_workspace.put(conv.workspace_path, conv.conversation_id) catch {}; + conv.messages.append(self.arena, .{ .kind = .user, .id = self.fmt("{s}-user", .{request_id}), .request_id = request_id, .text = self.fmt("/loop {s}", .{prompt}) }) catch {}; + conv.messages.append(self.arena, .{ .kind = .assistant, .id = self.fmt("{s}-assistant", .{request_id}), .request_id = request_id, .text = "Loop mode is not fully implemented in the Zig backend yet. This placeholder keeps the release UI flow active." }) catch {}; + conv.updated_at = nowMillis(); + self.writeConversationSessionFileLocked(conv); + self.saveGuiStateLocked(); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + + const snap = self.snapshotJson(req.allocator) catch return oom(); + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/loop\",\"body\":\"Started an autonomous plan-act-verify pass.\",\"snapshot\":") catch return oom(); + out.writer.writeAll(snap) catch return oom(); + out.writer.writeAll(",\"savedPath\":null,\"resultKind\":\"snapshot\",\"payload\":null}") catch return oom(); + return mer.json(out.written()); + } + + fn reasoningEffortCommand(self: *Runtime, req: mer.Request, args_text: []const u8) mer.Response { + self.ensurePromptSettingsLoaded(); + const level = std.mem.trim(u8, args_text, " \t\r\n"); + if (level.len == 0) { + const current = self.settings.selected_effort orelse default_reasoning_effort; + return commandText(req, "/reasoning-effort", self.fmt("Reasoning effort is **{s}**. Set it with /reasoning-effort .", .{current})); + } + if (!validReasoningEffort(level)) return commandText(req, "/reasoning-effort", "Usage: /reasoning-effort "); + self.mutex.lockUncancelable(mer_runtime.io); + self.settings.selected_effort = self.dupe(level); + self.savePromptSettingsLocked(); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return commandText(req, "/reasoning-effort", self.fmt("Reasoning effort is now **{s}**.", .{level})); + } + + fn workflowCommand(_: *Runtime, req: mer.Request, args_text: []const u8) mer.Response { + const goal = if (args_text.len > 0) args_text else "Draft a workflow."; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/workflow\",\"body\":\"Review the generated workflow before approving.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"workflowDraft\",\"payload\":{\"kind\":\"workflowDraft\",\"goal\":") catch return oom(); + writeString(&out.writer, goal) catch return oom(); + out.writer.writeAll(",\"summary\":\"Zig backend workflow draft placeholder.\",\"nodes\":[],\"exportText\":") catch return oom(); + writeString(&out.writer, std.fmt.allocPrint(req.allocator, "goal: {s}\nnodes: []", .{goal}) catch "nodes: []") catch return oom(); + out.writer.writeAll(",\"approvedPrompt\":") catch return oom(); + writeString(&out.writer, goal) catch return oom(); + out.writer.writeAll(",\"trace\":[\"Created workflow draft in the Zig backend\"]}}") catch return oom(); + return mer.json(out.written()); + } + + fn workspaceQueryCommand(_: *Runtime, req: mer.Request, root: Value, args_text: []const u8) mer.Response { + const workspace = stringField(root, "workspacePath") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/workspace-query\",\"body\":\"Workspace semantic search is not indexed by the Zig backend yet.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"workspaceSearch\",\"payload\":{\"kind\":\"workspaceSearch\",\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"query\":") catch return oom(); + writeString(&out.writer, args_text) catch return oom(); + out.writer.writeAll(",\"results\":[]}}") catch return oom(); + return mer.json(out.written()); + } + + fn workspaceInfoCommand(_: *Runtime, req: mer.Request, root: Value) mer.Response { + const workspace = stringField(root, "workspacePath") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/workspace-info\",\"body\":\"Workspace metadata loaded.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"workspaceInfo\",\"payload\":{\"kind\":\"workspaceInfo\",\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"workspaceId\":null,\"workingDir\":") catch return oom(); + writeNullableString(&out.writer, if (workspace.len == 0) null else workspace) catch return oom(); + out.writer.writeAll(",\"nodeCount\":null,\"relationCount\":null,\"lastUpdated\":null,\"createdAt\":null}}") catch return oom(); + return mer.json(out.written()); + } + + fn writeAgentsPayload(self: *Runtime, w: *std.Io.Writer, include_kind: bool) !void { + self.ensurePromptSettingsLoaded(); + const provider = self.settings.selected_provider orelse "codegraff"; + const model = self.settings.selected_model orelse "default"; + const effort = effectiveReasoningEffort(provider, model, self.settings.selected_effort); + const active = self.active_agent_id; + if (include_kind) try w.writeAll("{\"kind\":\"agents\",") else try w.writeByte('{'); + try w.writeAll("\"activeAgentId\":"); + try writeString(w, active); + try w.writeAll(",\"selectedProviderId\":"); + try writeString(w, provider); + try w.writeAll(",\"selectedModelId\":"); + try writeString(w, model); + try w.writeAll(",\"selectedReasoningEffort\":"); + try writeNullableString(w, effort); + try w.writeAll(",\"agents\":["); + inline for (.{ .{ "forge", "Forge", "Implementation assistant for coding tasks." }, .{ "muse", "Muse", "Planning assistant for read-only analysis." }, .{ "sage", "Sage", "Research assistant for deeper analysis." } }, 0..) |agent, i| { + if (i > 0) try w.writeByte(','); + try w.writeAll("{\"id\":"); + try writeString(w, agent[0]); + try w.writeAll(",\"title\":"); + try writeString(w, agent[1]); + try w.writeAll(",\"description\":"); + try writeString(w, agent[2]); + try w.writeAll(",\"isActive\":"); + try w.writeAll(if (std.mem.eql(u8, active, agent[0])) "true" else "false"); + try w.writeAll(",\"modelId\":"); + try writeString(w, model); + try w.writeByte('}'); + } + try w.writeAll("]}"); + } + + fn agentCommand(self: *Runtime, req: mer.Request) mer.Response { + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/agent\",\"body\":\"Active agent status.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"agents\",\"payload\":") catch return oom(); + self.writeAgentsPayload(&out.writer, true) catch return oom(); + out.writer.writeByte('}') catch return oom(); + return mer.json(out.written()); + } + + fn workspaceSync(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"events\":[\"Zig-native GUI MVP does not maintain a workspace index yet.\"]}") catch return oom(); + return mer.json(out.written()); + } + + fn workspaceQuery(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const workspace = stringField(input, "workspacePath") orelse ""; + const query = stringField(input, "query") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"query\":") catch return oom(); + writeString(&out.writer, query) catch return oom(); + out.writer.writeAll(",\"results\":[]}") catch return oom(); + return mer.json(out.written()); + } + + fn buildWorkflowDraft(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const goal = stringField(input, "goal") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"goal\":") catch return oom(); + writeString(&out.writer, goal) catch return oom(); + out.writer.writeAll(",\"summary\":\"Workflow drafting is stubbed in the Zig-native GUI MVP.\",\"nodes\":[],\"exportText\":") catch return oom(); + writeString(&out.writer, std.fmt.allocPrint(req.allocator, "goal: {s}\nnodes: []", .{goal}) catch "") catch return oom(); + out.writer.writeAll(",\"approvedPrompt\":") catch return oom(); + writeString(&out.writer, goal) catch return oom(); + out.writer.writeAll(",\"trace\":[]}") catch return oom(); + return mer.json(out.written()); + } + + fn exportWorkflowDraft(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const draft = objectField(root, "draft") orelse root; + const text = stringField(draft, "exportText") orelse ""; + var out: std.Io.Writer.Allocating = .init(req.allocator); + writeString(&out.writer, text) catch return oom(); + return mer.json(out.written()); + } + + fn setActiveAgent(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const agent = stringField(root, "agentId") orelse "forge"; + self.mutex.lockUncancelable(mer_runtime.io); + self.active_agent_id = self.dupe(agent); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + var out: std.Io.Writer.Allocating = .init(req.allocator); + self.writeAgentsPayload(&out.writer, false) catch return oom(); + return mer.json(out.written()); + } + + fn setEffort(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const level = stringField(root, "level") orelse return bad(req, "missing level"); + if (!validReasoningEffort(level)) return bad(req, "invalid reasoning effort"); + self.ensurePromptSettingsLoaded(); + self.mutex.lockUncancelable(mer_runtime.io); + self.settings.selected_effort = self.dupe(level); + self.savePromptSettingsLocked(); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return mer.json("{}"); + } + + fn setFast(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + self.ensurePromptSettingsLoaded(); + self.mutex.lockUncancelable(mer_runtime.io); + self.settings.fast_enabled = boolField(root, "on") orelse false; + self.savePromptSettingsLocked(); + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + return mer.json("{}"); + } + + fn listMcpServers(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch null; + const workspace = self.workspaceFromRequest(root); + return self.mcpSettingsResponse(req, workspace) catch return oom(); + } + + fn importMcpConfig(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const scope = stringField(input, "scope") orelse "local"; + if (!std.mem.eql(u8, scope, "local")) return bad(req, "only local workspace MCP config is supported"); + const json_text = stringField(input, "json") orelse return bad(req, "missing MCP config JSON"); + if (json_text.len > 1024 * 1024) return bad(req, "MCP config is too large"); + var tmp = std.heap.ArenaAllocator.init(req.allocator); + defer tmp.deinit(); + const parsed = std.json.parseFromSliceLeaky(Value, tmp.allocator(), json_text, .{ .allocate = .alloc_always }) catch return bad(req, "invalid MCP config JSON"); + if (parsed != .object or objectField(parsed, "mcpServers") == null) return bad(req, "MCP config must contain an mcpServers object"); + const workspace = self.workspaceFromRequest(root) orelse return bad(req, "open a workspace before importing local MCP config"); + writeMcpConfig(req.allocator, workspace, json_text) catch return bad(req, "failed to write MCP config"); + return self.mcpSettingsResponse(req, workspace) catch return oom(); + } + + fn removeMcpServer(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const scope = stringField(input, "scope") orelse "local"; + if (!std.mem.eql(u8, scope, "local")) return bad(req, "only local workspace MCP config is supported"); + const name = stringField(input, "name") orelse return bad(req, "missing MCP server name"); + const workspace = self.workspaceFromRequest(root) orelse return bad(req, "open a workspace before editing local MCP config"); + removeMcpServerFromConfig(req.allocator, workspace, name) catch |err| { + return bad(req, switch (err) { + error.McpConfigNotFound => "MCP config not found", + error.McpServerNotFound => "MCP server not found", + else => "failed to update MCP config", + }); + }; + return self.mcpSettingsResponse(req, workspace) catch return oom(); + } + + fn mcpCommand(self: *Runtime, req: mer.Request, root: Value) mer.Response { + const workspace = stringField(root, "workspacePath") orelse self.active_workspace_path; + const payload = renderMcpSettingsJson(req.allocator, workspace) catch return oom(); + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/mcp\",\"body\":\"Workspace MCP configuration loaded. Servers are shown from .mcp.json without executing them.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"mcp\",\"payload\":{\"kind\":\"mcp\",") catch return oom(); + out.writer.writeAll(payload[1 .. payload.len - 1]) catch return oom(); + out.writer.writeAll("}}") catch return oom(); + return mer.json(out.written()); + } + + fn mcpSettingsResponse(self: *Runtime, req: mer.Request, workspace: ?[]const u8) !mer.Response { + _ = self; + return mer.json(try renderMcpSettingsJson(req.allocator, workspace)); + } + + fn workspaceFromRequest(self: *Runtime, root: ?Value) ?[]const u8 { + if (root) |value| { + if (stringField(value, "workspacePath")) |workspace| return workspace; + if (objectField(value, "input")) |input| if (stringField(input, "workspacePath")) |workspace| return workspace; + } + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + return self.active_workspace_path; + } + + fn startProviderAuth(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const provider = stringField(input, "providerId") orelse "codegraff"; + const method = stringField(input, "authMethod") orelse "api_key"; + + if (std.mem.eql(u8, method, "codegraff_device")) { + const started = startCodegraffDevice(req.allocator) catch |err| { + return bad(req, authErrorMessage(err)); + }; + return deviceAuthResponse(req, provider, started); + } + if (std.mem.eql(u8, method, "kimi_device")) { + const started = startKimiDevice(req.allocator) catch |err| { + return bad(req, authErrorMessage(err)); + }; + return deviceAuthResponse(req, provider, started); + } + + var out: std.Io.Writer.Allocating = .init(req.allocator); + const kind = + if (std.mem.eql(u8, method, "api_key")) "api_key" else if (std.mem.eql(u8, method, "o_auth_code")) "o_auth_code" else if (std.mem.eql(u8, method, "o_auth_device")) "device_code" else if (std.mem.eql(u8, method, "codex_device")) "cli_login" else "cli_login"; + const requires_api_key = std.mem.eql(u8, kind, "api_key") and !std.mem.eql(u8, method, "google_adc"); + const sid = std.fmt.allocPrint(req.allocator, "codegraff-provider:{s}", .{provider}) catch provider; + out.writer.writeAll("{\"kind\":") catch return oom(); + writeString(&out.writer, kind) catch return oom(); + out.writer.writeAll(",\"authSessionId\":") catch return oom(); + writeString(&out.writer, sid) catch return oom(); + out.writer.writeAll(",\"requiresApiKey\":") catch return oom(); + out.writer.writeAll(if (requires_api_key) "true" else "false") catch return oom(); + out.writer.writeAll(",\"apiKeyHint\":") catch return oom(); + const cli_hint = + if (std.mem.eql(u8, method, "codex_device")) + "Codex uses the shared Codex CLI login. Run `codex login` in your terminal if needed, then finish setup here." + else + "Complete the provider login in your terminal, then finish setup here."; + writeNullableString(&out.writer, if (std.mem.eql(u8, kind, "cli_login")) cli_hint else "Paste an API key for this provider.") catch return oom(); + out.writer.writeAll(",\"urlParameters\":[],\"verificationUri\":") catch return oom(); + writeNullableString(&out.writer, null) catch return oom(); + out.writer.writeAll(",\"verificationUriComplete\":") catch return oom(); + writeNullableString(&out.writer, null) catch return oom(); + out.writer.writeAll(",\"userCode\":") catch return oom(); + writeNullableString(&out.writer, null) catch return oom(); + out.writer.writeAll(",\"expiresInSeconds\":null,\"authorizationUrl\":") catch return oom(); + writeNullableString(&out.writer, if (std.mem.eql(u8, kind, "o_auth_code")) "https://codegraff.dev/oauth" else null) catch return oom(); + out.writer.writeAll("}") catch return oom(); + return mer.json(out.written()); + } + + fn completeProviderAuth(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const sid = stringField(input, "authSessionId") orelse "codegraff-provider:codegraff"; + if (std.mem.startsWith(u8, sid, "codegraff-device:")) { + completeCodegraffDevice(req.allocator, sid["codegraff-device:".len..]) catch |err| { + return bad(req, authErrorMessage(err)); + }; + return providerSummaryResponse(req, "codegraff"); + } + if (std.mem.startsWith(u8, sid, "kimi-device:")) { + completeKimiDevice(req.allocator, sid["kimi-device:".len..]) catch |err| { + return bad(req, authErrorMessage(err)); + }; + return providerSummaryResponse(req, "kimi"); + } + const provider = std.mem.trim(u8, if (std.mem.startsWith(u8, sid, "codegraff-provider:")) sid["codegraff-provider:".len..] else sid, " "); + if (stringField(input, "apiKey")) |key| { + if (key.len == 0) return bad(req, "missing API key"); + storeProviderKey(req.allocator, provider, key) catch |err| { + return bad(req, authErrorMessage(err)); + }; + } + if (std.mem.eql(u8, provider, "codex") and !providerConfiguredById("codex", null)) { + return bad(req, "Codex is not logged in. Run `codex login` in your terminal, then click Finish setup again."); + } + return providerSummaryResponse(req, provider); + } + + fn removeProvider(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const provider = stringField(input, "providerId") orelse "codegraff"; + const info = providerInfo(provider); + if (providerConfigured(info.env_key)) { + const env_key = info.env_key orelse "the provider environment variable"; + var msg: std.Io.Writer.Allocating = .init(req.allocator); + msg.writer.print("credential removed from Codegraff's local store, but ${s} is still inherited by the running Codegraff app, so it is still being used.", .{env_key}) catch return oom(); + return bad(req, msg.written()); + } + removeStoredProvider(req.allocator, provider) catch {}; + return providerSummaryResponse(req, provider); + } + + fn readWorkspaceFile(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace = stringField(root, "workspacePath") orelse return bad(req, "missing workspacePath"); + const rel = stringField(root, "path") orelse return bad(req, "missing path"); + if (workspace.len == 0 or rel.len == 0 or std.fs.path.isAbsolute(rel) or hasParentPathComponent(rel)) return bad(req, "path is outside the workspace"); + const joined = std.fs.path.join(req.allocator, &.{ workspace, rel }) catch return oom(); + defer req.allocator.free(joined); + var root_buf: [std.fs.max_path_bytes]u8 = undefined; + var target_buf: [std.fs.max_path_bytes]u8 = undefined; + const root_len = std.Io.Dir.cwd().realPathFile(mer_runtime.io, workspace, &root_buf) catch return bad(req, "workspace path does not exist"); + const target_len = std.Io.Dir.cwd().realPathFile(mer_runtime.io, joined, &target_buf) catch return bad(req, "failed to read file"); + const root_real = root_buf[0..root_len]; + const target_real = target_buf[0..target_len]; + if (!pathIsWithinRoot(root_real, target_real)) return bad(req, "path is outside the workspace"); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, target_real, req.allocator, .limited(512 * 1024)) catch return bad(req, "failed to read file"); + var out: std.Io.Writer.Allocating = .init(req.allocator); + writeString(&out.writer, data) catch return oom(); + return mer.json(out.written()); + } + + fn savePastedImage(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const ext = stringField(root, "ext") orelse "png"; + const safe_ext = if (std.mem.eql(u8, ext, "jpg") or std.mem.eql(u8, ext, "jpeg") or std.mem.eql(u8, ext, "gif") or std.mem.eql(u8, ext, "bmp")) ext else "png"; + const path = std.fmt.allocPrint(req.allocator, "/tmp/codegraff-paste-{d}.{s}", .{ nowMillis(), safe_ext }) catch return oom(); + if (arrayField(root, "data")) |arr| { + var bytes = req.allocator.alloc(u8, arr.items.len) catch return oom(); + for (arr.items, 0..) |item, i| bytes[i] = if (item == .integer) @intCast(@max(0, @min(255, item.integer))) else 0; + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = bytes }) catch return bad(req, "failed to write image"); + } + var out: std.Io.Writer.Allocating = .init(req.allocator); + writeString(&out.writer, path) catch return oom(); + return mer.json(out.written()); + } + + fn saveAttachmentFile(_: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const name = stringField(input, "name") orelse "attachment.txt"; + const raw_data = stringField(input, "dataBase64") orelse return bad(req, "missing dataBase64"); + const data = stripDataUrlBase64(raw_data); + if (data.len > 96 * 1024 * 1024) return bad(req, "attachment is too large"); + + const decoder = std.base64.standard.Decoder; + const decoded_len = decoder.calcSizeForSlice(data) catch return bad(req, "invalid attachment data"); + if (decoded_len > 64 * 1024 * 1024) return bad(req, "attachment is too large"); + + const bytes = req.allocator.alloc(u8, decoded_len) catch return oom(); + decoder.decode(bytes, data) catch return bad(req, "invalid attachment data"); + + const safe_name = safeAttachmentFileName(req.allocator, name) catch return oom(); + const path = std.fmt.allocPrint(req.allocator, "/tmp/codegraff-attachment-{d}-{s}", .{ nowMillis(), safe_name }) catch return oom(); + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = bytes }) catch return bad(req, "failed to write attachment"); + + var out: std.Io.Writer.Allocating = .init(req.allocator); + writeString(&out.writer, path) catch return oom(); + return mer.json(out.written()); + } + + fn saveConversationLayout(_: *Runtime, req: mer.Request) mer.Response { + _ = parse(req) catch return badJson(req); + return mer.json("{}"); + } + + fn createSavedWorkspace(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const layout_json = stringField(input, "layoutJson") orelse "{}"; + const now = nowSeconds(); + const id = self.fmt("workspace-{d}", .{nowMillis()}); + const saved = SavedWorkspace{ + .id = id, + .name = "Saved workspace", + .layout_json = self.dupe(layout_json), + .updated_at = now, + }; + self.mutex.lockUncancelable(mer_runtime.io); + self.saved_workspaces.append(self.arena, saved) catch {}; + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.writeSavedWorkspaceDetail(req, saved); + } + + fn updateSavedWorkspaceLayout(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const workspace_id = stringField(input, "workspaceId") orelse return bad(req, "missing workspaceId"); + const layout_json = stringField(input, "layoutJson") orelse return bad(req, "missing layoutJson"); + self.mutex.lockUncancelable(mer_runtime.io); + for (self.saved_workspaces.items) |*saved| { + if (std.mem.eql(u8, saved.id, workspace_id)) { + saved.layout_json = self.dupe(layout_json); + saved.updated_at = nowSeconds(); + const detail = saved.*; + self.saveGuiStateLocked(); + self.mutex.unlock(mer_runtime.io); + return self.writeSavedWorkspaceDetail(req, detail); + } + } + self.mutex.unlock(mer_runtime.io); + return bad(req, "saved workspace not found"); + } + + fn getSavedWorkspace(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const workspace_id = stringField(root, "workspaceId") orelse stringField(root, "id") orelse return bad(req, "missing workspaceId"); + self.mutex.lockUncancelable(mer_runtime.io); + for (self.saved_workspaces.items) |saved| { + if (std.mem.eql(u8, saved.id, workspace_id)) { + const detail = saved; + self.mutex.unlock(mer_runtime.io); + return self.writeSavedWorkspaceDetail(req, detail); + } + } + self.mutex.unlock(mer_runtime.io); + return mer.json("null"); + } + + fn writeSavedWorkspaceDetail(_: *Runtime, req: mer.Request, saved: SavedWorkspace) mer.Response { + var out: std.Io.Writer.Allocating = .init(req.allocator); + const w = &out.writer; + w.writeAll("{\"id\":") catch return oom(); + writeString(w, saved.id) catch return oom(); + w.writeAll(",\"name\":") catch return oom(); + writeString(w, saved.name) catch return oom(); + w.writeAll(",\"layoutJson\":") catch return oom(); + writeString(w, saved.layout_json) catch return oom(); + w.writeAll(",\"updatedAt\":") catch return oom(); + w.print("{d}", .{saved.updated_at}) catch return oom(); + w.writeAll("}") catch return oom(); + return mer.json(out.written()); + } + + fn gitMutation(self: *Runtime, req: mer.Request, cmd: []const u8) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const workspace = stringField(input, "workspacePath") orelse stringField(root, "workspacePath") orelse ""; + if (workspace.len == 0) return bad(req, "missing workspacePath"); + self.mutex.lockUncancelable(mer_runtime.io); + runGitMutation(req.allocator, workspace, cmd, input) catch |err| { + self.mutex.unlock(mer_runtime.io); + return bad(req, gitMutationErrorMessage(err)); + }; + self.invalidateRuntimeStatusLocked(workspace); + self.mutex.unlock(mer_runtime.io); + return self.runtimeStatusJson(req.allocator, workspace) catch oom(); + } + + fn terminalOpen(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const terminal_id = stringField(input, "terminalId") orelse return bad(req, "missing terminalId"); + const workspace = stringField(input, "workspacePath") orelse return bad(req, "missing workspacePath"); + const cols = clampTerminalSize(intField(input, "cols", 80), 80); + const rows = clampTerminalSize(intField(input, "rows", 24), 24); + if (!fileExists(workspace)) return bad(req, "workspace path does not exist"); + + self.mutex.lockUncancelable(mer_runtime.io); + if (self.terminals.get(terminal_id)) |existing| { + if (!std.mem.eql(u8, existing.workspace_path, workspace)) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "terminal id belongs to a different workspace"); + } + existing.cols = cols; + existing.rows = rows; + existing.proc.resize(cols, rows) catch {}; + const shell = existing.shell; + const instance_id = existing.instance_id; + const stored_workspace = existing.workspace_path; + const scrollback = req.allocator.dupe(u8, existing.scrollback.items) catch { + self.mutex.unlock(mer_runtime.io); + return oom(); + }; + const truncated = existing.scrollback_truncated; + const scrollback_seq = existing.output_seq; + self.mutex.unlock(mer_runtime.io); + return terminalSessionJson(req.allocator, terminal_id, instance_id, stored_workspace, shell, cols, rows, scrollback, truncated, scrollback_seq); + } + self.mutex.unlock(mer_runtime.io); + + const shell = resolveShell(); + const proc = pty.spawnShell(self.arena, .{ + .shell = shell, + .cwd = workspace, + .cols = cols, + .rows = rows, + }) catch |err| { + log.err("failed to spawn PTY shell for terminal {s}: {}", .{ terminal_id, err }); + return mer.Response.init(.internal_server_error, .json, "{\"error\":\"failed to open terminal PTY\"}"); + }; + + const session = self.arena.create(TerminalSessionState) catch return oom(); + session.* = .{ + .terminal_id = self.dupe(terminal_id), + .instance_id = self.uniqueId("terminal-instance"), + .workspace_path = self.dupe(workspace), + .cwd = self.dupe(workspace), + .shell = shell, + .cols = cols, + .rows = rows, + .proc = proc, + }; + + self.mutex.lockUncancelable(mer_runtime.io); + if (self.terminals.get(terminal_id)) |winner| { + if (!std.mem.eql(u8, winner.workspace_path, workspace)) { + self.mutex.unlock(mer_runtime.io); + closeTerminalSession(session); + return bad(req, "terminal id belongs to a different workspace"); + } + const winner_terminal_id = winner.terminal_id; + const winner_instance_id = winner.instance_id; + const winner_workspace = winner.workspace_path; + const winner_shell = winner.shell; + const winner_cols = winner.cols; + const winner_rows = winner.rows; + const winner_scrollback = req.allocator.dupe(u8, winner.scrollback.items) catch { + self.mutex.unlock(mer_runtime.io); + closeTerminalSession(session); + return oom(); + }; + const winner_truncated = winner.scrollback_truncated; + const winner_scrollback_seq = winner.output_seq; + self.mutex.unlock(mer_runtime.io); + closeTerminalSession(session); + return terminalSessionJson(req.allocator, winner_terminal_id, winner_instance_id, winner_workspace, winner_shell, winner_cols, winner_rows, winner_scrollback, winner_truncated, winner_scrollback_seq); + } + self.terminals.put(session.terminal_id, session) catch { + self.mutex.unlock(mer_runtime.io); + closeTerminalSession(session); + return oom(); + }; + self.mutex.unlock(mer_runtime.io); + + const thread = std.Thread.spawn(.{}, terminalReaderMain, .{ self, session }) catch |err| { + log.err("failed to start PTY reader for terminal {s}: {}", .{ terminal_id, err }); + self.mutex.lockUncancelable(mer_runtime.io); + _ = self.terminals.remove(session.terminal_id); + self.mutex.unlock(mer_runtime.io); + closeTerminalSession(session); + return mer.Response.init(.internal_server_error, .json, "{\"error\":\"failed to start terminal reader\"}"); + }; + thread.detach(); + + return terminalSessionJson(req.allocator, terminal_id, session.instance_id, workspace, shell, cols, rows, "", false, 0); + } + + fn terminalWrite(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const terminal_id = stringField(input, "terminalId") orelse return bad(req, "missing terminalId"); + const terminal_instance_id = stringField(input, "terminalInstanceId") orelse return bad(req, "missing terminalInstanceId"); + const data = stringField(input, "data") orelse ""; + + self.mutex.lockUncancelable(mer_runtime.io); + const session = self.terminals.get(terminal_id) orelse { + self.mutex.unlock(mer_runtime.io); + return bad(req, "terminal session not found"); + }; + if (!self.terminalInstanceMatches(session, terminal_instance_id)) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "terminal session instance is no longer active"); + } + self.mutex.unlock(mer_runtime.io); + session.io_mutex.lockUncancelable(mer_runtime.io); + defer session.io_mutex.unlock(mer_runtime.io); + if (session.closing.load(.acquire)) return bad(req, "terminal session is closing"); + session.proc.writeAll(data) catch { + self.emitTerminalError(session, "terminal write failed"); + return mer.Response.init(.internal_server_error, .json, "{\"error\":\"terminal write failed\"}"); + }; + return mer.json("null"); + } + + fn terminalResize(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const terminal_id = stringField(input, "terminalId") orelse return bad(req, "missing terminalId"); + const terminal_instance_id = stringField(input, "terminalInstanceId") orelse return bad(req, "missing terminalInstanceId"); + const cols = clampTerminalSize(intField(input, "cols", 80), 80); + const rows = clampTerminalSize(intField(input, "rows", 24), 24); + self.mutex.lockUncancelable(mer_runtime.io); + if (self.terminals.get(terminal_id)) |session| { + if (!self.terminalInstanceMatches(session, terminal_instance_id)) { + self.mutex.unlock(mer_runtime.io); + return bad(req, "terminal session instance is no longer active"); + } + session.cols = cols; + session.rows = rows; + session.io_mutex.lockUncancelable(mer_runtime.io); + session.proc.resize(cols, rows) catch self.emitTerminalError(session, "terminal resize failed"); + session.io_mutex.unlock(mer_runtime.io); + } + self.mutex.unlock(mer_runtime.io); + return mer.json("null"); + } + + fn terminalClose(self: *Runtime, req: mer.Request) mer.Response { + const root = parse(req) catch return badJson(req); + const input = objectField(root, "input") orelse root; + const terminal_id = stringField(input, "terminalId") orelse return bad(req, "missing terminalId"); + const terminal_instance_id = stringField(input, "terminalInstanceId") orelse return bad(req, "missing terminalInstanceId"); + self.mutex.lockUncancelable(mer_runtime.io); + const session = self.terminals.get(terminal_id); + if (session) |s| { + if (!self.terminalInstanceMatches(s, terminal_instance_id)) { + self.mutex.unlock(mer_runtime.io); + return mer.json("null"); + } + } + _ = self.terminals.remove(terminal_id); + self.mutex.unlock(mer_runtime.io); + if (session) |s| closeTerminalSession(s); + return mer.json("null"); + } + + fn emitTerminalOutput(self: *Runtime, session: *TerminalSessionState, data: []const u8) void { + const seq = self.appendTerminalScrollback(session, data); + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"terminalId\":") catch return; + writeString(&out.writer, session.terminal_id) catch return; + out.writer.writeAll(",\"terminalInstanceId\":") catch return; + writeString(&out.writer, session.instance_id) catch return; + out.writer.writeAll(",\"terminalOutputSeq\":") catch return; + out.writer.print("{d}", .{seq}) catch return; + out.writer.writeAll(",\"data\":") catch return; + writeString(&out.writer, data) catch return; + out.writer.writeAll("}") catch return; + self.emitSseEvent("terminal-output", out.written()); + } + + fn emitTerminalExit(self: *Runtime, session: *TerminalSessionState, exit_code: i64) void { + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"terminalId\":") catch return; + writeString(&out.writer, session.terminal_id) catch return; + out.writer.writeAll(",\"terminalInstanceId\":") catch return; + writeString(&out.writer, session.instance_id) catch return; + out.writer.print(",\"exitCode\":{d},\"signal\":null}}", .{exit_code}) catch return; + self.emitSseEvent("terminal-exit", out.written()); + } + + fn emitTerminalError(self: *Runtime, session: *TerminalSessionState, message: []const u8) void { + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"terminalId\":") catch return; + writeString(&out.writer, session.terminal_id) catch return; + out.writer.writeAll(",\"terminalInstanceId\":") catch return; + writeString(&out.writer, session.instance_id) catch return; + out.writer.writeAll(",\"message\":") catch return; + writeString(&out.writer, message) catch return; + out.writer.writeAll("}") catch return; + self.emitSseEvent("terminal-error", out.written()); + } + + fn appendTerminalScrollback(self: *Runtime, session: *TerminalSessionState, data: []const u8) u64 { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + session.output_seq += 1; + const seq = session.output_seq; + session.scrollback.appendSlice(self.allocator, data) catch return seq; + if (session.scrollback.items.len > terminal_scrollback_limit) { + const drop = session.scrollback.items.len - terminal_scrollback_limit; + std.mem.copyForwards(u8, session.scrollback.items[0..terminal_scrollback_limit], session.scrollback.items[drop..]); + session.scrollback.items.len = terminal_scrollback_limit; + session.scrollback_truncated = true; + } + return seq; + } + + fn emitSseEvent(self: *Runtime, name: []const u8, data: []const u8) void { + const owned_name = self.allocator.dupe(u8, name) catch return; + const owned_data = self.allocator.dupe(u8, data) catch { + self.allocator.free(owned_name); + return; + }; + + self.event_mutex.lockUncancelable(mer_runtime.io); + defer self.event_mutex.unlock(mer_runtime.io); + self.events.append(self.allocator, .{ + .seq = self.next_event_seq, + .name = owned_name, + .data = owned_data, + }) catch { + self.allocator.free(owned_name); + self.allocator.free(owned_data); + return; + }; + self.next_event_seq += 1; + if (self.events.items.len > sse_event_replay_limit) { + const drop = self.events.items.len - sse_event_replay_limit; + for (self.events.items[0..drop]) |event| self.freeSseEvent(event); + std.mem.copyForwards(SseEvent, self.events.items[0..sse_event_replay_limit], self.events.items[drop..]); + self.events.items.len = sse_event_replay_limit; + } + } + + fn freeSseEvent(self: *Runtime, event: SseEvent) void { + self.allocator.free(event.name); + self.allocator.free(event.data); + } + + fn newestEventSeqLocked(self: *Runtime) u64 { + return if (self.next_event_seq > 0) self.next_event_seq - 1 else 0; + } + + fn clampRequestedEventSeqLocked(self: *Runtime, requested: u64) u64 { + const newest = self.newestEventSeqLocked(); + if (self.events.items.len == 0) return if (requested > newest) newest else requested; + const oldest = self.events.items[0].seq; + const replay_from = if (oldest > 0) oldest - 1 else 0; + if (requested > newest) return replay_from; + if (requested < replay_from) return replay_from; + return requested; + } + + fn terminalInstanceMatches(_: *Runtime, session: *TerminalSessionState, requested: ?[]const u8) bool { + return requested == null or std.mem.eql(u8, session.instance_id, requested.?); + } + + fn requestActiveLocked(self: *Runtime, cid: []const u8, rid: []const u8) bool { + const conv = self.conversations.get(cid) orelse return false; + for (conv.active_request_ids.items) |active| { + if (std.mem.eql(u8, active, rid)) return true; + } + return false; + } + + fn requestHasMessageKind(self: *Runtime, cid: []const u8, rid: []const u8, kind: Message.Kind) bool { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + const conv = self.conversations.get(cid) orelse return false; + for (conv.messages.items) |message| { + if (message.kind == kind and std.mem.eql(u8, message.request_id, rid)) return true; + } + return false; + } + + fn workspaceStatusCommand(self: *Runtime, req: mer.Request, root: Value) mer.Response { + _ = self; + const workspace = stringField(root, "workspacePath") orelse ""; + const files = gitStatusFiles(req.allocator, workspace) catch &.{}; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":\"/workspace-status\",\"body\":\"Workspace status loaded.\",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"workspaceStatus\",\"payload\":{\"kind\":\"workspaceStatus\",\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"files\":[") catch return oom(); + for (files, 0..) |file, i| { + if (i > 0) out.writer.writeByte(',') catch return oom(); + out.writer.writeAll("{\"path\":") catch return oom(); + writeString(&out.writer, file.path) catch return oom(); + out.writer.writeAll(",\"status\":") catch return oom(); + writeString(&out.writer, file.status) catch return oom(); + out.writer.writeAll("}") catch return oom(); + } + out.writer.writeAll("]}}") catch return oom(); + return mer.json(out.written()); + } + + fn ensureGraffSession(self: *Runtime, conversation_id: []const u8, session_name: []const u8, workspace: []const u8, agent_id: []const u8, plan_mode: bool, provider: ?[]const u8, model: ?[]const u8) !*GraffSession { + const io = mer_runtime.io; + const desired_yolo = !plan_mode and std.mem.eql(u8, agent_id, "forge"); + self.mutex.lockUncancelable(io); + if (self.graff_sessions.get(conversation_id)) |session| { + if (session.yolo_enabled == desired_yolo and std.mem.eql(u8, session.workspace_path, workspace) and std.mem.eql(u8, session.session_name, session_name) and optionalStringEql(session.desired_provider, provider) and optionalStringEql(session.desired_model, model)) { + self.mutex.unlock(io); + return session; + } + if (self.graff_sessions.fetchRemove(conversation_id)) |entry| self.closeGraffSession(entry.value); + } + self.mutex.unlock(io); + + const bin = self.codegraffBinary(); + var argv: std.ArrayList([]const u8) = .empty; + defer argv.deinit(self.allocator); + try argv.append(self.allocator, "/usr/bin/env"); + try argv.append(self.allocator, try std.fmt.allocPrint(self.allocator, "HOME={s}", .{homeDir()})); + try argv.append(self.allocator, try std.fmt.allocPrint(self.allocator, "PATH={s}/bin:{s}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", .{ homeDir(), homeDir() })); + try argv.append(self.allocator, bin); + try argv.append(self.allocator, "--json"); + if (desired_yolo) try argv.append(self.allocator, "--yolo"); + if (session_name.len > 0 and std.mem.indexOfAny(u8, session_name, "/\\") == null) { + try argv.append(self.allocator, "--resume"); + try argv.append(self.allocator, session_name); + } + + const child = try self.allocator.create(std.process.Child); + errdefer self.allocator.destroy(child); + child.* = try std.process.spawn(io, .{ + .argv = argv.items, + .cwd = .{ .path = workspace }, + .stdin = .pipe, + .stdout = .pipe, + .stderr = .ignore, + }); + const session = try self.allocator.create(GraffSession); + errdefer { + child.kill(io); + self.allocator.destroy(session); + } + session.* = .{ + .conversation_id = self.dupe(conversation_id), + .workspace_path = self.dupe(workspace), + .session_name = self.dupe(session_name), + .desired_provider = if (provider) |p| self.dupe(p) else null, + .desired_model = if (model) |m| self.dupe(m) else null, + .child = child, + .yolo_enabled = desired_yolo, + }; + + self.mutex.lockUncancelable(io); + if (self.graff_sessions.get(conversation_id)) |_| { + self.mutex.unlock(io); + self.closeGraffSession(session); + return error.SessionRace; + } + self.graff_sessions.put(session.conversation_id, session) catch { + self.mutex.unlock(io); + self.closeGraffSession(session); + return error.OutOfMemory; + }; + self.mutex.unlock(io); + return session; + } + + fn dropGraffSession(self: *Runtime, conversation_id: []const u8) void { + if (self.graff_sessions.fetchRemove(conversation_id)) |entry| self.closeGraffSession(entry.value); + } + + fn dropGraffSessionIfCurrent(self: *Runtime, conversation_id: []const u8, session: *GraffSession) void { + const current = self.graff_sessions.get(conversation_id) orelse return; + if (current == session) { + if (self.graff_sessions.fetchRemove(conversation_id)) |entry| self.closeGraffSession(entry.value); + } + } + + fn closeGraffSession(self: *Runtime, session: *GraffSession) void { + _ = self; + // Turn threads may still hold the session pointer while a stop/cancel + // unblocks their stdout read. Kill immediately, but leave reclamation to + // process teardown rather than risking use-after-free across threads. + session.child.kill(mer_runtime.io); + } + + fn retryGraffTurnAfterSetupFailure(self: *Runtime, conversation_id: []const u8, request_id: []const u8, session: *GraffSession, session_name: []const u8, workspace: []const u8, prompt: []const u8, agent_id: []const u8, plan_mode: bool) anyerror!void { + self.mutex.lockUncancelable(mer_runtime.io); + self.dropGraffSessionIfCurrent(conversation_id, session); + self.mutex.unlock(mer_runtime.io); + return self.streamGraffTurn(conversation_id, request_id, session_name, workspace, prompt, agent_id, plan_mode, false); + } + + fn streamGraffTurn(self: *Runtime, conversation_id: []const u8, request_id: []const u8, session_name: []const u8, workspace: []const u8, prompt: []const u8, agent_id: []const u8, plan_mode: bool, retry_on_setup_failure: bool) anyerror!void { + const io = mer_runtime.io; + self.ensurePromptSettingsLoaded(); + var selection_arena = std.heap.ArenaAllocator.init(self.allocator); + defer selection_arena.deinit(); + const prompt_selection = try self.resolvedTurnPromptSelection(selection_arena.allocator(), conversation_id); + const model = prompt_selection.model; + const provider = prompt_selection.provider; + const effort = prompt_selection.effort; + const fast_enabled = prompt_selection.fast_enabled; + + const session = try self.ensureGraffSession(conversation_id, session_name, workspace, agent_id, plan_mode, provider, model); + self.mutex.lockUncancelable(io); + self.active_children.put(request_id, session.child) catch {}; + const active_after_spawn = self.requestActiveLocked(conversation_id, request_id); + self.mutex.unlock(io); + defer { + self.mutex.lockUncancelable(io); + if (self.active_children.get(request_id) == session.child) _ = self.active_children.remove(request_id); + self.mutex.unlock(io); + } + if (!active_after_spawn) { + self.mutex.lockUncancelable(io); + self.dropGraffSessionIfCurrent(conversation_id, session); + self.mutex.unlock(io); + return; + } + var drop_session_on_return = false; + defer if (drop_session_on_return) { + self.mutex.lockUncancelable(io); + self.dropGraffSessionIfCurrent(conversation_id, session); + self.mutex.unlock(io); + }; + errdefer { + self.mutex.lockUncancelable(io); + self.dropGraffSessionIfCurrent(conversation_id, session); + self.mutex.unlock(io); + } + + var wbuf: [4096]u8 = undefined; + var cw = session.child.stdin.?.writerStreaming(io, &wbuf); + const rbuf = try self.allocator.alloc(u8, 1024 * 1024); + defer self.allocator.free(rbuf); + var rdr = session.child.stdout.?.readerStreaming(io, rbuf); + var protocol_warning_count: usize = 0; + + if (prompt_selection.send_model_control) if (provider) |p| if (model) |m| { + if (p.len > 0 and m.len > 0 and !std.mem.eql(u8, m, "default") and (!optionalStringEql(session.acked_provider, provider) or !optionalStringEql(session.acked_model, model))) { + var control: std.Io.Writer.Allocating = .init(self.allocator); + defer control.deinit(); + try control.writer.writeAll("{\"type\":\"set_model\",\"name\":"); + try writeString(&control.writer, self.fmt("{s}/{s}", .{ p, m })); + try control.writer.writeByte('}'); + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, control.written(), "model", &protocol_warning_count, retry_on_setup_failure, true)) { + if (retry_on_setup_failure and !self.requestHasMessageKind(conversation_id, request_id, .@"error")) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + drop_session_on_return = true; + return; + } + session.acked_provider = self.dupe(p); + session.acked_model = self.dupe(m); + } + }; + if (effort) |level| { + if (!optionalStringEql(session.acked_effort, effort)) { + var control: std.Io.Writer.Allocating = .init(self.allocator); + defer control.deinit(); + try control.writer.writeAll("{\"type\":\"set_effort\",\"level\":"); + try writeString(&control.writer, level); + try control.writer.writeByte('}'); + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, control.written(), "effort", &protocol_warning_count, retry_on_setup_failure, true)) { + if (retry_on_setup_failure and !self.requestHasMessageKind(conversation_id, request_id, .@"error")) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + drop_session_on_return = true; + return; + } + session.acked_effort = self.dupe(level); + } + } + if (session.acked_fast == null or session.acked_fast.? != fast_enabled) { + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, if (fast_enabled) "{\"type\":\"set_fast\",\"on\":true}" else "{\"type\":\"set_fast\",\"on\":false}", "fast", &protocol_warning_count, retry_on_setup_failure, true)) { + if (retry_on_setup_failure and !self.requestHasMessageKind(conversation_id, request_id, .@"error")) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + drop_session_on_return = true; + return; + } + session.acked_fast = fast_enabled; + } + const core_agent = guiAgentCoreAgent(agent_id) orelse ""; + if (!optionalStringEql(session.acked_agent, core_agent)) { + var control: std.Io.Writer.Allocating = .init(self.allocator); + defer control.deinit(); + try control.writer.writeAll("{\"type\":\"set_agent\",\"id\":"); + try writeString(&control.writer, core_agent); + try control.writer.writeByte('}'); + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, control.written(), "agent", &protocol_warning_count, retry_on_setup_failure, true)) { + if (retry_on_setup_failure and !self.requestHasMessageKind(conversation_id, request_id, .@"error")) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + drop_session_on_return = true; + return; + } + session.acked_agent = self.dupe(core_agent); + } + const mode = if (plan_mode) "plan" else "normal"; + if (!optionalStringEql(session.acked_mode, mode)) { + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, if (plan_mode) "{\"type\":\"set_mode\",\"mode\":\"plan\"}" else "{\"type\":\"set_mode\",\"mode\":\"normal\"}", "mode", &protocol_warning_count, retry_on_setup_failure, true)) { + if (retry_on_setup_failure and !self.requestHasMessageKind(conversation_id, request_id, .@"error")) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + drop_session_on_return = true; + return; + } + session.acked_mode = self.dupe(mode); + } + + var user_req: std.Io.Writer.Allocating = .init(self.allocator); + defer user_req.deinit(); + try user_req.writer.writeAll("{\"type\":\"user\",\"text\":"); + try writeString(&user_req.writer, prompt); + try user_req.writer.writeByte('}'); + cw.interface.writeAll(user_req.written()) catch |err| { + if (retry_on_setup_failure) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + return err; + }; + cw.interface.writeByte('\n') catch |err| { + if (retry_on_setup_failure) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + return err; + }; + cw.interface.flush() catch |err| { + if (retry_on_setup_failure) return self.retryGraffTurnAfterSetupFailure(conversation_id, request_id, session, session_name, workspace, prompt, agent_id, plan_mode); + return err; + }; + self.recordModelUsage(provider, model); + + var assistant_seq: usize = 0; + var reasoning_seq: usize = 0; + var current_assistant: ?[]const u8 = null; + var current_reasoning: ?[]const u8 = null; + var event_count: usize = 0; + var session_failed = false; + var saw_turn = false; + while (true) { + const ev_line = rdr.interface.takeDelimiter('\n') catch |err| { + self.mutex.lockUncancelable(io); + const still_active = self.requestActiveLocked(conversation_id, request_id); + self.mutex.unlock(io); + if (still_active) log.warn("graff read ended with {}", .{err}); + session_failed = true; + break; + } orelse { + self.mutex.lockUncancelable(io); + const still_active = self.requestActiveLocked(conversation_id, request_id); + self.mutex.unlock(io); + if (still_active) log.warn("graff read ended with EOF", .{}); + session_failed = true; + break; + }; + const line = std.mem.trim(u8, ev_line, " \t\r\n"); + if (line.len == 0) continue; + self.mutex.lockUncancelable(io); + const still_active = self.requestActiveLocked(conversation_id, request_id); + self.mutex.unlock(io); + if (!still_active) break; + var turn_arena = std.heap.ArenaAllocator.init(self.allocator); + defer turn_arena.deinit(); + const event = std.json.parseFromSliceLeaky(Value, turn_arena.allocator(), line, .{}) catch |err| { + log.warn("graff event parse failed: {}", .{err}); + event_count += 1; + protocol_warning_count = self.appendProtocolWarningLimited(conversation_id, request_id, protocol_warning_count, self.fmt("{s}-malformed-event-{d}", .{ request_id, event_count }), "Malformed graff JSONL event", line); + continue; + }; + const ty = stringField(event, "type") orelse { + log.warn("graff event missing type", .{}); + event_count += 1; + const raw = self.valueJson(event) catch line; + protocol_warning_count = self.appendProtocolWarningLimited(conversation_id, request_id, protocol_warning_count, self.fmt("{s}-missing-type-event-{d}", .{ request_id, event_count }), "Graff JSONL event missing type", raw); + continue; + }; + event_count += 1; + if (std.mem.eql(u8, ty, "text")) { + const delta = stringField(event, "text") orelse ""; + if (delta.len == 0) continue; + current_reasoning = null; + if (current_assistant == null) { + current_assistant = self.fmt("{s}-assistant-{d}", .{ request_id, assistant_seq }); + assistant_seq += 1; + } + self.appendDelta(conversation_id, request_id, current_assistant.?, .assistant, delta); + } else if (std.mem.eql(u8, ty, "reasoning")) { + const delta = stringField(event, "text") orelse ""; + if (delta.len == 0) continue; + current_assistant = null; + if (current_reasoning == null) { + current_reasoning = self.fmt("{s}-reasoning-{d}", .{ request_id, reasoning_seq }); + reasoning_seq += 1; + } + self.appendDelta(conversation_id, request_id, current_reasoning.?, .reasoning, delta); + } else if (std.mem.eql(u8, ty, "tool_call")) { + current_assistant = null; + const name = stringField(event, "name") orelse "tool"; + const input = objectField(event, "input") orelse event; + const id = self.fmt("{s}-tool-{d}", .{ request_id, nowMillis() }); + const call_id = stringField(event, "call_id") orelse stringField(event, "id") orelse id; + self.appendToolStart(conversation_id, request_id, id, name, call_id, stringField(input, "question") orelse "", input); + } else if (std.mem.eql(u8, ty, "ask_user")) { + current_assistant = null; + const input = objectField(event, "input") orelse event; + const call_id = stringField(event, "call_id") orelse self.uniqueId("ask-user"); + const question = stringField(input, "question") orelse ""; + const options = self.followupOptions(input); + const kind = if (options.len > 0) "single" else "text"; + const id = self.fmt("{s}-tool-{d}", .{ request_id, nowMillis() }); + self.appendToolStart(conversation_id, request_id, id, "ask_user", call_id, question, input); + self.setFollowup(conversation_id, request_id, workspace, call_id, question, kind, options); + } else if (std.mem.eql(u8, ty, "tool_result")) { + current_assistant = null; + const name = stringField(event, "name") orelse "tool"; + const text = stringField(event, "text") orelse ""; + const is_error = boolField(event, "is_error") orelse false; + const call_id = stringField(event, "call_id") orelse stringField(event, "id"); + self.appendToolEnd(conversation_id, request_id, self.fmt("{s}-toolend-{d}", .{ request_id, nowMillis() }), name, call_id, text, is_error); + } else if (self.handleProtocolAck(conversation_id, request_id, ty, event)) { + current_assistant = null; + } else if (std.mem.eql(u8, ty, "error")) { + const msg = stringField(event, "message") orelse "graff error"; + self.appendError(conversation_id, request_id, msg); + session_failed = true; + break; + } else if (std.mem.eql(u8, ty, "turn")) { + const final_text = stringField(event, "text") orelse ""; + if (final_text.len > 0 and !self.requestHasMessageKind(conversation_id, request_id, .assistant)) { + self.appendDelta(conversation_id, request_id, self.fmt("{s}-assistant-final", .{request_id}), .assistant, final_text); + } + saw_turn = true; + break; + } else { + const raw = self.valueJson(event) catch line; + self.appendStatusOutput(conversation_id, request_id, self.fmt("{s}-unknown-event-{d}", .{ request_id, event_count }), self.fmt("Unhandled graff event: {s}", .{ty}), raw); + } + } + if (saw_turn and (session.acked_fast == null or session.acked_fast.? != fast_enabled)) { + var post_turn_warnings: usize = protocol_warning_count; + if (!try self.sendControlAndWait(&rdr, &cw, conversation_id, request_id, if (fast_enabled) "{\"type\":\"set_fast\",\"on\":true}" else "{\"type\":\"set_fast\",\"on\":false}", "fast", &post_turn_warnings, true, false)) { + session_failed = true; + } else { + session.acked_fast = fast_enabled; + } + } + + self.mutex.lockUncancelable(io); + const completed_still_active = self.requestActiveLocked(conversation_id, request_id); + self.mutex.unlock(io); + if (event_count == 0 and completed_still_active) { + self.appendError(conversation_id, request_id, "graff exited before producing a response"); + } + if (session_failed) drop_session_on_return = true; + } + + fn appendDelta(self: *Runtime, cid: []const u8, rid: []const u8, id: []const u8, kind: Message.Kind, delta: []const u8) void { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + var i = conv.messages.items.len; + while (i > 0) { + i -= 1; + if (std.mem.eql(u8, conv.messages.items[i].id, id)) { + conv.messages.items[i].text = self.fmt("{s}{s}", .{ conv.messages.items[i].text, delta }); + conv.updated_at = nowMillis(); + self.emitMessageDeltaLocked(conv, rid, id, kind, delta); + return; + } + } + conv.messages.append(self.arena, .{ .kind = kind, .id = id, .request_id = rid, .text = self.dupe(delta) }) catch {}; + conv.updated_at = nowMillis(); + self.emitMessageDeltaLocked(conv, rid, id, kind, delta); + } + } + + fn emitMessageDeltaLocked(self: *Runtime, conv: *Conversation, rid: []const u8, id: []const u8, kind: Message.Kind, delta: []const u8) void { + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"conversationId\":") catch return; + writeString(&out.writer, conv.conversation_id) catch return; + out.writer.writeAll(",\"workspacePath\":") catch return; + writeString(&out.writer, conv.workspace_path) catch return; + out.writer.writeAll(",\"requestId\":") catch return; + writeString(&out.writer, rid) catch return; + out.writer.writeAll(",\"messageId\":") catch return; + writeString(&out.writer, id) catch return; + out.writer.writeAll(",\"kind\":") catch return; + writeString(&out.writer, @tagName(kind)) catch return; + out.writer.writeAll(",\"text\":") catch return; + writeString(&out.writer, delta) catch return; + out.writer.writeByte('}') catch return; + self.emitSseEvent("message-delta", out.written()); + } + + fn emitRequestEventLocked(self: *Runtime, event_name: []const u8, conv: *Conversation, rid: []const u8) void { + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"conversationId\":") catch return; + writeString(&out.writer, conv.conversation_id) catch return; + out.writer.writeAll(",\"workspacePath\":") catch return; + writeString(&out.writer, conv.workspace_path) catch return; + out.writer.writeAll(",\"requestId\":") catch return; + writeString(&out.writer, rid) catch return; + out.writer.writeByte('}') catch return; + self.emitSseEvent(event_name, out.written()); + } + + fn appendToolStart(self: *Runtime, cid: []const u8, rid: []const u8, id: []const u8, name: []const u8, call_id: ?[]const u8, question: []const u8, input: Value) void { + const detail = self.toolCallDetailJson(name, question, input); + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + conv.messages.append(self.arena, .{ .kind = .tool_start, .id = id, .request_id = rid, .name = self.dupe(name), .call_id = if (call_id) |c| self.dupe(c) else null, .question = self.dupe(question), .tool_detail_json = detail }) catch {}; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + } + + fn appendToolEnd(self: *Runtime, cid: []const u8, rid: []const u8, id: []const u8, name: []const u8, call_id: ?[]const u8, text: []const u8, is_error: bool) void { + const detail = self.toolResultDetailJson(name, text); + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + conv.messages.append(self.arena, .{ .kind = .tool_end, .id = id, .request_id = rid, .name = self.dupe(name), .call_id = if (call_id) |c| self.dupe(c) else null, .summary = firstLine(self.arena, text), .text = self.dupe(text), .is_error = is_error, .result_detail_json = detail }) catch {}; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + } + + fn toolCallDetailJson(self: *Runtime, name: []const u8, question: []const u8, input: Value) []const u8 { + var out: std.Io.Writer.Allocating = .init(self.arena); + const w = &out.writer; + if (std.mem.eql(u8, name, "bash")) { + w.writeAll("{\"kind\":\"shell\",\"command\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, stringField(input, "command") orelse stringField(input, "cmd") orelse "") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll(",\"cwd\":null,\"description\":null}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "read_file")) { + w.writeAll("{\"kind\":\"file_read\",\"path\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, stringField(input, "path") orelse "") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll(",\"startLine\":null,\"endLine\":null}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "write_file") or std.mem.eql(u8, name, "edit_file")) { + w.writeAll("{\"kind\":\"file_update\",\"path\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, stringField(input, "path") orelse "") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll(",\"operation\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, if (std.mem.eql(u8, name, "write_file")) "overwrite" else "replace") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll("}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "webfetch")) { + w.writeAll("{\"kind\":\"fetch\",\"url\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, stringField(input, "url") orelse "") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll("}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "ask_user")) { + w.writeAll("{\"kind\":\"followup\",\"question\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, question) catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll("}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "todo_read")) { + w.writeAll("{\"kind\":\"todo_read\"}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "todo_write")) { + w.writeAll("{\"kind\":\"todo_write\",\"count\":0}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else if (std.mem.eql(u8, name, "codedb")) { + w.writeAll("{\"kind\":\"codebase_search\",\"queries\":[") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, stringField(input, "command") orelse "") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll("]}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } else { + w.writeAll("{\"kind\":\"unknown\",\"name\":") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + writeString(w, name) catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + w.writeAll("}") catch return "{\"kind\":\"unknown\",\"name\":\"tool\"}"; + } + return out.written(); + } + + fn toolResultDetailJson(self: *Runtime, name: []const u8, text: []const u8) []const u8 { + var out: std.Io.Writer.Allocating = .init(self.arena); + const w = &out.writer; + if (std.mem.eql(u8, name, "bash")) { + w.writeAll("{\"kind\":\"shell_output\",\"command\":\"\",\"shell\":\"sh\",\"exitCode\":null,\"description\":null,\"stdout\":{\"content\":") catch return "{\"kind\":\"text\",\"text\":\"\"}"; + writeString(w, text) catch return "{\"kind\":\"text\",\"text\":\"\"}"; + w.writeAll(",\"totalLines\":0,\"headDisplayLines\":null,\"tailDisplayLines\":null,\"fullOutputPath\":null},\"stderr\":null}") catch return "{\"kind\":\"text\",\"text\":\"\"}"; + } else { + w.writeAll("{\"kind\":\"text\",\"text\":") catch return "{\"kind\":\"text\",\"text\":\"\"}"; + writeString(w, text) catch return "{\"kind\":\"text\",\"text\":\"\"}"; + w.writeAll("}") catch return "{\"kind\":\"text\",\"text\":\"\"}"; + } + return out.written(); + } + + fn appendStatusOutput(self: *Runtime, cid: []const u8, rid: []const u8, id: []const u8, title: []const u8, output: []const u8) void { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + conv.messages.append(self.arena, .{ .kind = .status, .id = id, .request_id = rid, .title = self.dupe(title), .subtitle = "Preserved raw event for protocol compatibility.", .category = "debug" }) catch {}; + conv.messages.append(self.arena, .{ .kind = .status_output, .id = self.fmt("{s}-output", .{id}), .request_id = rid, .text = self.dupe(output) }) catch {}; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + } + + fn appendProtocolWarning(self: *Runtime, cid: []const u8, rid: []const u8, id: []const u8, title: []const u8, raw: []const u8) void { + var allocated = false; + const text = if (std.unicode.utf8ValidateSlice(raw)) raw else blk: { + const sanitized = sanitizeTerminalOutput(self.allocator, raw) catch break :blk ""; + allocated = true; + break :blk sanitized; + }; + defer if (allocated) self.allocator.free(text); + self.appendStatusOutput(cid, rid, id, title, text); + } + + fn appendProtocolWarningLimited(self: *Runtime, cid: []const u8, rid: []const u8, count: usize, id: []const u8, title: []const u8, raw: []const u8) usize { + if (count < protocol_warning_limit_per_turn) { + self.appendProtocolWarning(cid, rid, id, title, raw); + } else if (count == protocol_warning_limit_per_turn) { + self.appendProtocolWarning(cid, rid, id, "Additional graff protocol events suppressed", "Further malformed or untyped graff JSONL events were omitted to keep the GUI transcript bounded."); + } + return count + 1; + } + + fn sendControlAndWait(self: *Runtime, rdr: anytype, cw: anytype, cid: []const u8, rid: []const u8, request_line: []const u8, ack_type: []const u8, warning_count: *usize, suppress_transport_errors: bool, handle_ack: bool) !bool { + cw.interface.writeAll(request_line) catch |err| { + log.warn("graff {s} control write failed with {}", .{ ack_type, err }); + if (!suppress_transport_errors) self.appendError(cid, rid, self.fmt("graff {s} control did not acknowledge", .{ack_type})); + return false; + }; + cw.interface.writeByte('\n') catch |err| { + log.warn("graff {s} control write failed with {}", .{ ack_type, err }); + if (!suppress_transport_errors) self.appendError(cid, rid, self.fmt("graff {s} control did not acknowledge", .{ack_type})); + return false; + }; + cw.interface.flush() catch |err| { + log.warn("graff {s} control flush failed with {}", .{ ack_type, err }); + if (!suppress_transport_errors) self.appendError(cid, rid, self.fmt("graff {s} control did not acknowledge", .{ack_type})); + return false; + }; + + while (true) { + const ev_line = rdr.interface.takeDelimiter('\n') catch |err| { + log.warn("graff control read ended with {}", .{err}); + if (!suppress_transport_errors) self.appendError(cid, rid, self.fmt("graff {s} control did not acknowledge", .{ack_type})); + return false; + } orelse { + if (!suppress_transport_errors) self.appendError(cid, rid, self.fmt("graff exited before {s} control acknowledged", .{ack_type})); + return false; + }; + const line = std.mem.trim(u8, ev_line, " \t\r\n"); + if (line.len == 0) continue; + + var control_arena = std.heap.ArenaAllocator.init(self.allocator); + defer control_arena.deinit(); + const event = std.json.parseFromSliceLeaky(Value, control_arena.allocator(), line, .{}) catch |err| { + log.warn("graff control event parse failed: {}", .{err}); + warning_count.* = self.appendProtocolWarningLimited(cid, rid, warning_count.*, self.fmt("{s}-control-malformed-{d}", .{ rid, warning_count.* + 1 }), "Malformed graff JSONL event", line); + continue; + }; + const ty = stringField(event, "type") orelse { + const raw = self.valueJson(event) catch line; + warning_count.* = self.appendProtocolWarningLimited(cid, rid, warning_count.*, self.fmt("{s}-control-missing-type-{d}", .{ rid, warning_count.* + 1 }), "Graff JSONL event missing type", raw); + continue; + }; + if (std.mem.eql(u8, ty, "error")) { + self.appendError(cid, rid, stringField(event, "message") orelse self.fmt("graff {s} control failed", .{ack_type})); + return false; + } + if (std.mem.eql(u8, ty, ack_type)) { + if (handle_ack) _ = self.handleProtocolAck(cid, rid, ty, event); + return true; + } + const raw = self.valueJson(event) catch line; + warning_count.* = self.appendProtocolWarningLimited(cid, rid, warning_count.*, self.fmt("{s}-control-unexpected-{d}", .{ rid, warning_count.* + 1 }), self.fmt("Unexpected graff setup event: {s}", .{ty}), raw); + } + } + + fn handleProtocolAck(self: *Runtime, cid: []const u8, rid: []const u8, ty: []const u8, event: Value) bool { + if (!isProtocolAckEvent(ty)) return false; + if (!(boolField(event, "ok") orelse true)) { + self.appendError(cid, rid, self.fmt("graff {s} control failed", .{ty})); + return true; + } + + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + const conv = self.conversations.get(cid) orelse return true; + if (!self.requestActiveLocked(cid, rid)) return true; + if (std.mem.eql(u8, ty, "model")) { + if (stringField(event, "provider")) |provider| conv.session_provider = self.dupe(provider); + if (stringField(event, "model")) |model| conv.session_model = self.dupe(model); + } else if (std.mem.eql(u8, ty, "mode")) { + if (stringField(event, "mode")) |mode| conv.plan_mode = std.mem.eql(u8, mode, "plan"); + } else if (std.mem.eql(u8, ty, "ultracode")) { + if (boolField(event, "on")) |on| conv.session_ultracode_mode = on; + } + conv.updated_at = nowMillis(); + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + return true; + } + + fn appendError(self: *Runtime, cid: []const u8, rid: []const u8, msg: []const u8) void { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + conv.messages.append(self.arena, .{ .kind = .@"error", .id = self.fmt("{s}-error-{d}", .{ rid, nowMillis() }), .request_id = rid, .error_message = self.dupe(msg) }) catch {}; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + } + + fn followupOptions(self: *Runtime, input: Value) []const FollowupOption { + const raw_options = arrayField(input, "options") orelse return &.{}; + var options: std.ArrayList(FollowupOption) = .empty; + for (raw_options.items, 0..) |item, idx| { + const label = switch (item) { + .string => |s| s, + .object => |obj| strFieldObj(obj, "label") orelse strFieldObj(obj, "text") orelse strFieldObj(obj, "value") orelse continue, + else => continue, + }; + if (label.len == 0) continue; + const id = switch (item) { + .object => |obj| strFieldObj(obj, "id") orelse self.fmt("opt-{d}", .{idx + 1}), + else => self.fmt("opt-{d}", .{idx + 1}), + }; + options.append(self.arena, .{ .id = self.dupe(id), .label = self.dupe(label) }) catch {}; + } + return options.toOwnedSlice(self.arena) catch &.{}; + } + + fn setFollowup(self: *Runtime, cid: []const u8, rid: []const u8, workspace: []const u8, call_id: []const u8, question: []const u8, kind: []const u8, options: []const FollowupOption) void { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.conversations.get(cid)) |conv| { + if (!self.requestActiveLocked(cid, rid)) return; + const stored_options = self.arena.alloc(FollowupOption, options.len) catch @panic("oom"); + for (options, 0..) |option, idx| stored_options[idx] = .{ .id = self.dupe(option.id), .label = self.dupe(option.label) }; + conv.followup = .{ .followup_id = self.dupe(call_id), .workspace_path = self.dupe(workspace), .conversation_id = conv.conversation_id, .request_id = rid, .kind = self.dupe(kind), .question = self.dupe(question), .options = stored_options, .call_id = self.dupe(call_id) }; + self.writeConversationSessionFileLocked(conv); + self.bumpLocked(); + } + } + + fn snapshotJson(self: *Runtime, alloc: std.mem.Allocator) ![]const u8 { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + var out: std.Io.Writer.Allocating = .init(alloc); + const w = &out.writer; + try w.writeAll("{\"activeWorkspacePath\":"); + try writeNullableString(w, self.active_workspace_path); + try w.writeAll(",\"activeConversationId\":"); + try writeNullableString(w, self.active_conversation_id); + + const visible = if (self.active_conversation_id) |cid| self.conversations.get(cid) else null; + try w.writeAll(",\"visibleMessages\":"); + try self.writeMessages(w, if (visible) |v| v.messages.items else &.{}); + try w.writeAll(",\"visibleActiveRequestIds\":"); + try writeStringArray(w, if (visible) |v| v.active_request_ids.items else &.{}); + try w.writeAll(",\"visibleRequestAgentIds\":{},\"visibleTodos\":[]"); + if (visible) |v| { + if (v.goal) |goal| { + try w.writeAll(",\"visibleGoal\":"); + try writeString(w, goal); + } + } + try w.writeAll(",\"visibleFollowup\":"); + if (visible) |v| try self.writeFollowup(w, v.followup) else try w.writeAll("null"); + try w.writeAll(",\"conversationViews\":["); + if (visible) |v| try self.writeConversationView(w, v); + try w.writeAll("],\"uiError\":null,\"workspaces\":["); + for (self.workspaces.items, 0..) |workspace, idx| { + if (idx > 0) try w.writeByte(','); + try self.writeWorkspace(w, workspace); + } + try w.writeAll("],\"savedWorkspaces\":["); + for (self.saved_workspaces.items, 0..) |saved, idx| { + if (idx > 0) try w.writeByte(','); + try w.writeAll("{\"id\":"); + try writeString(w, saved.id); + try w.writeAll(",\"name\":"); + try writeString(w, saved.name); + try w.writeAll(",\"updatedAt\":"); + try w.print("{d}", .{saved.updated_at}); + try w.writeByte('}'); + } + try w.writeAll("]}"); + return out.written(); + } + + fn writeConversationView(self: *Runtime, w: *std.Io.Writer, conv: *Conversation) !void { + try w.writeAll("{\"workspacePath\":"); + try writeString(w, conv.workspace_path); + try w.writeAll(",\"conversationId\":"); + try writeString(w, conv.conversation_id); + try w.writeAll(",\"messages\":"); + try self.writeMessages(w, conv.messages.items); + try w.writeAll(",\"activeRequestIds\":"); + try writeStringArray(w, conv.active_request_ids.items); + try w.writeAll(",\"requestAgentIds\":{},\"todos\":[]"); + if (conv.goal) |goal| { + try w.writeAll(",\"goal\":"); + try writeString(w, goal); + } + try w.writeAll(",\"followup\":"); + try self.writeFollowup(w, conv.followup); + try w.writeAll("}"); + } + + fn writeWorkspace(self: *Runtime, w: *std.Io.Writer, workspace: Workspace) !void { + try w.writeAll("{\"kind\":"); + try writeString(w, workspace.kind); + try w.writeAll(",\"workspacePath\":"); + try writeString(w, workspace.path); + try w.writeAll(",\"workspaceName\":"); + try writeString(w, workspace.display_name orelse workspaceName(workspace.path)); + try w.writeAll(",\"configured\":true,\"configurationError\":null,\"selectedConversationId\":"); + try writeNullableString(w, self.selected_by_workspace.get(workspace.path)); + try w.writeAll(",\"conversations\":["); + var first = true; + var it = self.conversations.iterator(); + while (it.next()) |entry| { + const conv = entry.value_ptr.*; + if (!std.mem.eql(u8, conv.workspace_path, workspace.path)) continue; + if (!first) try w.writeByte(','); + first = false; + try w.writeAll("{\"conversationId\":"); + try writeString(w, conv.conversation_id); + try w.writeAll(",\"title\":"); + try writeString(w, conv.title); + try w.writeAll(",\"updatedAt\":"); + if (conv.updated_at > 0) { + var buf: [32]u8 = undefined; + try writeString(w, try std.fmt.bufPrint(&buf, "{d}", .{conv.updated_at})); + } else try w.writeAll("null"); + try w.writeAll(",\"isDraft\":"); + try w.writeAll(if (conv.messages.items.len == 0) "true" else "false"); + try w.writeAll(",\"isRunning\":"); + try w.writeAll(if (conv.active_request_ids.items.len > 0) "true" else "false"); + try w.writeAll(",\"hasPendingFollowup\":"); + try w.writeAll(if (conv.followup != null) "true" else "false"); + try w.writeAll("}"); + } + try w.writeAll("]}"); + } + + fn writeMessages(self: *Runtime, w: *std.Io.Writer, messages: []const Message) !void { + _ = self; + try w.writeByte('['); + for (messages, 0..) |message, idx| { + if (idx > 0) try w.writeByte(','); + try w.writeAll("{\"kind\":"); + try writeString(w, @tagName(message.kind)); + try w.writeAll(",\"id\":"); + try writeString(w, message.id); + try w.writeAll(",\"requestId\":"); + try writeString(w, message.request_id); + switch (message.kind) { + .user, .context_compacted, .assistant, .reasoning, .status_output => { + try w.writeAll(",\"text\":"); + try writeString(w, message.text); + }, + .status => { + try w.writeAll(",\"title\":"); + try writeString(w, message.title); + try w.writeAll(",\"subtitle\":"); + try writeNullableString(w, message.subtitle); + try w.writeAll(",\"category\":"); + try writeString(w, message.category); + }, + .tool_start => { + try w.writeAll(",\"name\":"); + try writeString(w, message.name); + try w.writeAll(",\"callId\":"); + try writeNullableString(w, message.call_id); + try w.writeAll(",\"detail\":"); + if (message.tool_detail_json) |detail| try w.writeAll(detail) else { + try w.writeAll("{\"kind\":\"unknown\",\"name\":"); + try writeString(w, message.name); + try w.writeAll("}"); + } + }, + .tool_end => { + try w.writeAll(",\"name\":"); + try writeString(w, message.name); + try w.writeAll(",\"callId\":"); + try writeNullableString(w, message.call_id); + try w.writeAll(",\"summary\":"); + try writeNullableString(w, message.summary); + try w.writeAll(",\"isError\":"); + try w.writeAll(if (message.is_error) "true" else "false"); + try w.writeAll(",\"detail\":"); + if (message.result_detail_json) |detail| try w.writeAll(detail) else if (message.text.len > 0) { + try w.writeAll("{\"kind\":\"text\",\"text\":"); + try writeString(w, message.text); + try w.writeAll("}"); + } else try w.writeAll("null"); + }, + .@"error" => { + try w.writeAll(",\"message\":"); + try writeString(w, message.error_message); + }, + } + try w.writeAll("}"); + } + try w.writeByte(']'); + } + + fn writeFollowup(_: *Runtime, w: *std.Io.Writer, followup: ?Followup) !void { + const f = followup orelse { + try w.writeAll("null"); + return; + }; + try w.writeAll("{\"followupId\":"); + try writeString(w, f.followup_id); + try w.writeAll(",\"workspacePath\":"); + try writeString(w, f.workspace_path); + try w.writeAll(",\"conversationId\":"); + try writeString(w, f.conversation_id); + try w.writeAll(",\"requestId\":"); + try writeString(w, f.request_id); + try w.writeAll(",\"kind\":"); + try writeString(w, f.kind); + try w.writeAll(",\"question\":"); + try writeString(w, f.question); + try w.writeAll(",\"options\":["); + for (f.options, 0..) |option, idx| { + if (idx > 0) try w.writeByte(','); + try w.writeAll("{\"id\":"); + try writeString(w, option.id); + try w.writeAll(",\"label\":"); + try writeString(w, option.label); + try w.writeByte('}'); + } + try w.writeAll("]}"); + } + + fn runtimeStatusJson(self: *Runtime, alloc: std.mem.Allocator, path: ?[]const u8) !mer.Response { + const p = path orelse ""; + const now = nowMillis(); + self.mutex.lockUncancelable(mer_runtime.io); + const generation = self.runtime_status_generation; + if (self.runtime_status_cache.get(p)) |entry| { + if (entry.expires_ms > now) { + const cached = alloc.dupe(u8, entry.json) catch { + self.mutex.unlock(mer_runtime.io); + return error.OutOfMemory; + }; + self.mutex.unlock(mer_runtime.io); + return mer.json(cached); + } + } + self.mutex.unlock(mer_runtime.io); + + const rendered = try renderRuntimeStatusJson(alloc, p); + const key = try self.allocator.dupe(u8, p); + errdefer self.allocator.free(key); + const value = try self.allocator.dupe(u8, rendered); + errdefer self.allocator.free(value); + + self.mutex.lockUncancelable(mer_runtime.io); + if (!self.storeRuntimeStatusCacheLocked(p, key, value, now + runtime_status_cache_ttl_ms, generation)) { + self.mutex.unlock(mer_runtime.io); + self.allocator.free(key); + self.allocator.free(value); + return mer.json(rendered); + } + self.mutex.unlock(mer_runtime.io); + + return mer.json(rendered); + } + + fn storeRuntimeStatusCacheLocked(self: *Runtime, p: []const u8, key: []const u8, value: []const u8, expires_ms: i64, generation: u64) bool { + if (self.runtime_status_generation != generation) return false; + if (self.runtime_status_cache.fetchRemove(p)) |old| { + self.allocator.free(old.key); + self.allocator.free(old.value.json); + } + self.runtime_status_cache.put(key, .{ .json = value, .expires_ms = expires_ms }) catch return false; + return true; + } + + fn invalidateRuntimeStatusLocked(self: *Runtime, path: ?[]const u8) void { + self.runtime_status_generation +%= 1; + if (path) |p| { + if (self.runtime_status_cache.fetchRemove(p)) |old| { + self.allocator.free(old.key); + self.allocator.free(old.value.json); + } + return; + } + var it = self.runtime_status_cache.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.json); + } + self.runtime_status_cache.clearRetainingCapacity(); + } + + fn promptSettingsJson(self: *Runtime, alloc: std.mem.Allocator) ![]const u8 { + const schema = self.cachedSchemaValue(alloc); + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + const selected = resolvePromptSelection(schema, self.settings.selected_provider, self.settings.selected_model); + var out: std.Io.Writer.Allocating = .init(alloc); + try out.writer.writeAll("{\"availableModels\":"); + try writePromptModels(alloc, &out.writer, schema, &self.model_usage); + try out.writer.writeAll(",\"selectedProviderId\":"); + try writeNullableString(&out.writer, selected.provider); + try out.writer.writeAll(",\"selectedModelId\":"); + try writeNullableString(&out.writer, selected.model); + try out.writer.writeAll(",\"selectedReasoningEffort\":"); + try writeNullableString(&out.writer, effectiveReasoningEffort(selected.provider, selected.model, self.settings.selected_effort)); + try out.writer.writeAll(",\"fastEnabled\":"); + try out.writer.writeAll(if (self.settings.fast_enabled) "true" else "false"); + try out.writer.writeAll(",\"fastApplies\":"); + try out.writer.writeAll(if (selected.provider != null and std.mem.eql(u8, selected.provider.?, "codex")) "true" else "false"); + try out.writer.writeAll("}"); + return out.written(); + } + + fn cachedSchemaValue(self: *Runtime, alloc: std.mem.Allocator) ?Value { + self.mutex.lockUncancelable(mer_runtime.io); + if (self.schema_loaded) { + const raw = self.schema_raw; + self.mutex.unlock(mer_runtime.io); + if (raw) |data| return std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch null; + return null; + } + self.mutex.unlock(mer_runtime.io); + + const raw_loaded = commandOutput(alloc, &.{ self.codegraffBinary(), "--schema" }) catch null; + + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (!self.schema_loaded) { + if (raw_loaded) |raw| { + self.schema_raw = self.arena.dupe(u8, raw) catch null; + self.schema_loaded = self.schema_raw != null; + } + } + const raw = self.schema_raw; + if (raw) |data| return std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch null; + return null; + } + + fn createConversationLocked(self: *Runtime, workspace: []const u8, cid_raw: []const u8, title_raw: []const u8) *Conversation { + const conv = self.createConversationWithSessionLocked(workspace, cid_raw, cid_raw, title_raw); + self.setActiveWorkspaceLocked(conv.workspace_path); + return conv; + } + + fn createConversationWithSessionLocked(self: *Runtime, workspace: []const u8, cid_raw: []const u8, session_raw: []const u8, title_raw: []const u8) *Conversation { + const wpath = self.dupe(workspace); + const cid = self.dupe(cid_raw); + const session_name = self.dupe(session_raw); + const conv = self.arena.create(Conversation) catch @panic("oom"); + conv.* = .{ + .workspace_path = wpath, + .conversation_id = cid, + .session_name = session_name, + .title = self.dupe(title_raw), + .active_agent_id = self.active_agent_id, + .updated_at = 0, + }; + self.conversations.put(cid, conv) catch {}; + return conv; + } + + fn setActiveWorkspaceLocked(self: *Runtime, path: []const u8) void { + self.active_workspace_path = path; + if (self.workspaceIndexLocked(path) == null) { + self.workspaces.insert(self.arena, 0, .{ .path = path }) catch {}; + } + } + + fn activateWorkspaceLocked(self: *Runtime, path: []const u8) void { + self.setActiveWorkspaceLocked(path); + self.active_conversation_id = self.selected_by_workspace.get(path); + } + + fn workspaceIndexLocked(self: *Runtime, path: []const u8) ?usize { + for (self.workspaces.items, 0..) |workspace, i| { + if (std.mem.eql(u8, workspace.path, path)) return i; + } + return null; + } + + fn turnPromptSelectionLocked(self: *Runtime, conversation_id: []const u8) TurnPromptSelection { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + const conv = self.conversations.get(conversation_id); + const session_provider = if (conv) |c| c.session_provider else null; + const session_model = if (conv) |c| c.session_model else null; + const provider = self.settings.selected_provider orelse session_provider; + const model = self.settings.selected_model orelse session_model; + return .{ + .provider = provider, + .model = model, + .effort = effectiveReasoningEffort(provider, model, self.settings.selected_effort), + .fast_enabled = self.settings.fast_enabled, + .send_model_control = true, + }; + } + + fn resolvedTurnPromptSelection(self: *Runtime, alloc: std.mem.Allocator, conversation_id: []const u8) !TurnPromptSelection { + const raw = self.turnPromptSelectionLocked(conversation_id); + const resolved = if (raw.provider == null or raw.model == null) blk: { + const schema = self.cachedSchemaValue(alloc); + break :blk resolvePromptSelection(schema, raw.provider, raw.model); + } else PromptSelection{ .provider = raw.provider, .model = raw.model }; + const provider = if (resolved.provider) |value| try alloc.dupe(u8, value) else null; + const model = if (resolved.model) |value| try alloc.dupe(u8, value) else null; + return .{ + .provider = provider, + .model = model, + .effort = effectiveReasoningEffort(provider, model, raw.effort), + .fast_enabled = raw.fast_enabled, + .send_model_control = raw.send_model_control, + }; + } + + fn selectedModelLocked(self: *Runtime) ?[]const u8 { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + return self.settings.selected_model; + } + + fn selectedProviderLocked(self: *Runtime) ?[]const u8 { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + return self.settings.selected_provider; + } + + fn selectedEffortFor(self: *Runtime, provider: ?[]const u8, model: ?[]const u8) ?[]const u8 { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + return effectiveReasoningEffort(provider, model, self.settings.selected_effort); + } + + fn codegraffBinary(_: *Runtime) []const u8 { + const home = homeDir(); + const p1 = std.fmt.allocPrint(std.heap.page_allocator, "{s}/bin/graff", .{home}) catch return "graff"; + if (fileExists(p1)) return p1; + const p2 = std.fmt.allocPrint(std.heap.page_allocator, "{s}/.local/bin/graff", .{home}) catch return "graff"; + if (fileExists(p2)) return p2; + inline for (.{ "/opt/homebrew/bin/graff", "/usr/local/bin/graff", "/usr/bin/graff" }) |p| { + if (fileExists(p)) return p; + } + return "graff"; + } + + fn createManagedChatPath(self: *Runtime) []const u8 { + const home = homeDir(); + const path = self.fmt("{s}/Library/Application Support/dev.codegraff.gui/managed-chats/chat_{d}", .{ home, nowMillis() }); + return path; + } + + fn loadGuiState(self: *Runtime) void { + const path = guiStatePath(self.allocator) catch return; + defer self.allocator.free(path); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, self.allocator, .limited(1024 * 1024)) catch return; + defer self.allocator.free(data); + + var tmp = std.heap.ArenaAllocator.init(self.allocator); + defer tmp.deinit(); + const parsed = std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }) catch return; + if (parsed != .object) return; + + if (arrayField(parsed, "savedWorkspaces")) |saved_workspaces| { + for (saved_workspaces.items) |item| { + if (item != .object) continue; + const id = stringField(item, "id") orelse continue; + const name = stringField(item, "name") orelse "Saved workspace"; + const layout_json = stringField(item, "layoutJson") orelse "{}"; + const updated_at = intField(item, "updatedAt", nowSeconds()); + self.saved_workspaces.append(self.arena, .{ + .id = self.dupe(id), + .name = self.dupe(name), + .layout_json = self.dupe(layout_json), + .updated_at = updated_at, + }) catch {}; + } + } + + if (arrayField(parsed, "workspaces")) |workspaces| { + for (workspaces.items) |item| { + if (item != .object) continue; + const raw_path = stringField(item, "path") orelse stringField(item, "workspacePath") orelse continue; + if (!self.stateWorkspaceAvailable(raw_path)) continue; + const owned_path = self.dupe(raw_path); + const kind = if (stringField(item, "kind")) |value| self.dupe(value) else "project"; + const display_name = if (stringField(item, "displayName")) |value| self.dupe(value) else null; + if (self.workspaceIndexLocked(owned_path)) |idx| { + self.workspaces.items[idx].kind = kind; + self.workspaces.items[idx].display_name = display_name; + } else { + self.workspaces.append(self.arena, .{ .path = owned_path, .kind = kind, .display_name = display_name }) catch {}; + } + if (stringField(item, "selectedConversationId")) |selected| { + self.selected_by_workspace.put(owned_path, self.dupe(selected)) catch {}; + } + self.scanWorkspaceSessionsLocked(owned_path, true); + self.ensureWorkspaceSelectionLocked(owned_path); + } + } + + const active_workspace = stringField(parsed, "activeWorkspacePath"); + const active_conversation = stringField(parsed, "activeConversationId"); + if (active_workspace) |workspace| { + if (self.workspaceIndexLocked(workspace) != null) { + self.active_workspace_path = self.dupe(workspace); + if (active_conversation) |cid| { + if (self.conversations.get(cid)) |conv| { + if (std.mem.eql(u8, conv.workspace_path, workspace)) { + self.active_conversation_id = conv.conversation_id; + self.selected_by_workspace.put(conv.workspace_path, conv.conversation_id) catch {}; + } + } + } + if (self.active_conversation_id == null) { + self.active_conversation_id = self.selected_by_workspace.get(workspace); + } + return; + } + } + + if (self.workspaces.items.len > 0) { + const workspace = self.workspaces.items[0].path; + self.active_workspace_path = workspace; + self.active_conversation_id = self.selected_by_workspace.get(workspace); + } + } + + fn ensureGuiStateLoaded(self: *Runtime) void { + if (builtin.is_test) return; + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.gui_state_loaded) return; + self.gui_state_loaded = true; + self.loadGuiState(); + } + + fn saveGuiStateLocked(self: *Runtime) void { + if (builtin.is_test) return; + ensureSettingsDir(self.allocator); + const path = guiStatePath(self.allocator) catch return; + defer self.allocator.free(path); + + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + const w = &out.writer; + w.writeAll("{\"activeWorkspacePath\":") catch return; + writeNullableString(w, self.active_workspace_path) catch return; + w.writeAll(",\"activeConversationId\":") catch return; + writeNullableString(w, self.active_conversation_id) catch return; + w.writeAll(",\"workspaces\":[") catch return; + for (self.workspaces.items, 0..) |workspace, idx| { + if (idx > 0) w.writeByte(',') catch return; + w.writeAll("{\"path\":") catch return; + writeString(w, workspace.path) catch return; + w.writeAll(",\"kind\":") catch return; + writeString(w, workspace.kind) catch return; + w.writeAll(",\"displayName\":") catch return; + writeNullableString(w, workspace.display_name) catch return; + w.writeAll(",\"selectedConversationId\":") catch return; + writeNullableString(w, self.selected_by_workspace.get(workspace.path)) catch return; + w.writeAll("}") catch return; + } + w.writeAll("],\"savedWorkspaces\":[") catch return; + for (self.saved_workspaces.items, 0..) |saved, idx| { + if (idx > 0) w.writeByte(',') catch return; + w.writeAll("{\"id\":") catch return; + writeString(w, saved.id) catch return; + w.writeAll(",\"name\":") catch return; + writeString(w, saved.name) catch return; + w.writeAll(",\"layoutJson\":") catch return; + writeString(w, saved.layout_json) catch return; + w.writeAll(",\"updatedAt\":") catch return; + w.print("{d}", .{saved.updated_at}) catch return; + w.writeAll("}") catch return; + } + w.writeAll("]}") catch return; + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = out.written() }) catch {}; + } + + fn stateWorkspaceAvailable(self: *Runtime, path: []const u8) bool { + if (fileExists(path)) return true; + if (!isGeneratedManagedChatPath(self.allocator, path)) return false; + ensureDirectory(self.allocator, path) catch return false; + return true; + } + + fn scanWorkspaceSessionsLocked(self: *Runtime, workspace_path: []const u8, force: bool) void { + const now = nowMillis(); + if (!force) { + if (self.session_scan_cache.get(workspace_path)) |entry| { + if (entry.scanned_ms + session_scan_cache_ttl_ms > now) return; + } + } + var dir = std.Io.Dir.cwd().openDir(mer_runtime.io, workspace_path, .{ .iterate = true }) catch return; + defer dir.close(mer_runtime.io); + var it = dir.iterate(); + while (it.next(mer_runtime.io) catch null) |entry| { + if (entry.kind != .file) continue; + if (!std.mem.endsWith(u8, entry.name, session_ext)) continue; + const base = entry.name[0 .. entry.name.len - session_ext.len]; + if (base.len == 0 or std.mem.indexOfAny(u8, base, "/\\") != null) continue; + const full_path = sessionFilePath(self.allocator, workspace_path, base) catch continue; + self.importSessionFileLocked(workspace_path, base, full_path); + self.allocator.free(full_path); + } + self.markSessionScanLocked(workspace_path, now); + self.ensureWorkspaceSelectionLocked(workspace_path); + } + + fn markSessionScanLocked(self: *Runtime, workspace_path: []const u8, scanned_ms: i64) void { + const key = self.allocator.dupe(u8, workspace_path) catch return; + if (self.session_scan_cache.fetchRemove(workspace_path)) |old| self.allocator.free(old.key); + self.session_scan_cache.put(key, .{ .scanned_ms = scanned_ms }) catch self.allocator.free(key); + } + + fn invalidateSessionScanLocked(self: *Runtime, workspace_path: []const u8) void { + if (self.session_scan_cache.fetchRemove(workspace_path)) |old| self.allocator.free(old.key); + } + + fn importSessionFileLocked(self: *Runtime, workspace_path: []const u8, session_name: []const u8, full_path: []const u8) void { + var tmp = std.heap.ArenaAllocator.init(self.allocator); + defer tmp.deinit(); + const tmp_alloc = tmp.allocator(); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, full_path, tmp_alloc, .limited(8 * 1024 * 1024)) catch return; + const parsed = std.json.parseFromSliceLeaky(Value, tmp_alloc, data, .{ .allocate = .alloc_always }) catch return; + if (parsed != .object) return; + self.importSessionValueLocked(workspace_path, session_name, parsed, tmp_alloc); + } + + fn importSessionValueLocked(self: *Runtime, workspace_path: []const u8, session_name: []const u8, parsed: Value, parse_alloc: std.mem.Allocator) void { + if (parsed != .object) return; + const cid = self.conversationIdForSession(workspace_path, session_name); + const title_value = stringField(parsed, "title"); + const initial_title = if (title_value) |title| (if (title.len > 0) title else session_name) else session_name; + const title_needs_prompt = title_value == null or isDefaultConversationTitle(initial_title) or std.mem.eql(u8, initial_title, session_name); + const conv = self.conversations.get(cid) orelse self.createConversationWithSessionLocked(workspace_path, cid, session_name, initial_title); + conv.session_name = self.dupe(session_name); + conv.title = self.dupe(initial_title); + conv.updated_at = intField(parsed, "updated_ms", conv.updated_at); + conv.goal = if (stringField(parsed, "goal")) |goal| (if (goal.len > 0) self.dupe(goal) else null) else null; + conv.session_provider = if (stringField(parsed, "provider")) |provider| self.dupe(provider) else conv.session_provider; + conv.session_model = if (stringField(parsed, "model")) |model| self.dupe(model) else conv.session_model; + conv.session_strict = boolField(parsed, "strict") orelse conv.session_strict; + conv.session_ultracode_mode = boolField(parsed, "ultracode_mode") orelse conv.session_ultracode_mode; + if (parsed.object.get("messages")) |messages_value| { + conv.cli_messages_json = self.valueJson(messages_value) catch null; + } + + if (conv.active_request_ids.items.len > 0) return; + conv.messages.clearRetainingCapacity(); + var first_user_title: ?[]const u8 = null; + if (arrayField(parsed, "guiMessages")) |gui_messages| { + for (gui_messages.items, 0..) |message, idx| { + if (self.importGuiMessageLocked(conv, message, idx)) |user_title| { + if (first_user_title == null) first_user_title = user_title; + } + } + } else if (arrayField(parsed, "messages")) |messages| { + for (messages.items, 0..) |message, idx| { + if (message != .object) continue; + const role = stringField(message, "role") orelse continue; + const raw_text = sessionMessageText(parse_alloc, message); + const text = std.mem.trim(u8, raw_text, " \t\r\n"); + const reasoning_raw = sessionMessageReasoning(message); + const reasoning = if (reasoning_raw) |value| std.mem.trim(u8, value, " \t\r\n") else ""; + const rid = self.fmt("{s}-loaded-{d}", .{ conv.conversation_id, idx }); + if (std.mem.eql(u8, role, "user")) { + if (text.len == 0) continue; + if (first_user_title == null) first_user_title = titleFromPrompt(self.arena, text); + conv.messages.append(self.arena, .{ + .kind = .user, + .id = self.fmt("{s}-user", .{rid}), + .request_id = rid, + .text = self.dupe(text), + }) catch {}; + } else if (std.mem.eql(u8, role, "assistant")) { + if (reasoning.len > 0) { + conv.messages.append(self.arena, .{ + .kind = .reasoning, + .id = self.fmt("{s}-reasoning", .{rid}), + .request_id = rid, + .text = self.dupe(reasoning), + }) catch {}; + } + if (text.len > 0) { + conv.messages.append(self.arena, .{ + .kind = .assistant, + .id = self.fmt("{s}-assistant", .{rid}), + .request_id = rid, + .text = self.dupe(text), + }) catch {}; + } + } + } + } + if (objectField(parsed, "followup")) |followup| { + conv.followup = self.importFollowupLocked(conv, followup); + } else conv.followup = null; + if (title_needs_prompt and first_user_title != null) { + conv.title = first_user_title.?; + self.renameManagedChatWorkspaceLocked(workspace_path, conv.title); + self.writeConversationSessionFileLocked(conv); + } + } + + fn importGuiMessageLocked(self: *Runtime, conv: *Conversation, value: Value, idx: usize) ?[]const u8 { + if (value != .object) return null; + const kind_name = stringField(value, "kind") orelse return null; + const kind = messageKindFromString(kind_name) orelse return null; + const rid = if (stringField(value, "requestId")) |request_id| self.dupe(request_id) else self.fmt("{s}-loaded-{d}", .{ conv.conversation_id, idx }); + const id = if (stringField(value, "id")) |message_id| self.dupe(message_id) else self.fmt("{s}-{s}", .{ rid, kind_name }); + var message = Message{ + .kind = kind, + .id = id, + .request_id = rid, + }; + switch (kind) { + .user, .context_compacted, .assistant, .reasoning, .status_output => { + message.text = if (stringField(value, "text")) |text| self.dupe(text) else ""; + }, + .status => { + message.title = if (stringField(value, "title")) |title| self.dupe(title) else "Status"; + message.subtitle = if (stringField(value, "subtitle")) |subtitle| self.dupe(subtitle) else null; + message.category = if (stringField(value, "category")) |category| self.dupe(category) else "info"; + }, + .tool_start => { + message.name = if (stringField(value, "name")) |name| self.dupe(name) else "tool"; + message.call_id = if (stringField(value, "callId")) |call_id| self.dupe(call_id) else null; + message.question = if (stringField(value, "question")) |question| self.dupe(question) else ""; + if (objectField(value, "detail")) |detail| message.tool_detail_json = self.valueJson(detail) catch null; + }, + .tool_end => { + message.name = if (stringField(value, "name")) |name| self.dupe(name) else "tool"; + message.call_id = if (stringField(value, "callId")) |call_id| self.dupe(call_id) else null; + message.summary = if (stringField(value, "summary")) |summary| self.dupe(summary) else null; + message.is_error = boolField(value, "isError") orelse false; + message.text = if (stringField(value, "text")) |text| self.dupe(text) else ""; + if (objectField(value, "detail")) |detail| message.result_detail_json = self.valueJson(detail) catch null; + }, + .@"error" => { + message.error_message = if (stringField(value, "message")) |msg| self.dupe(msg) else ""; + }, + } + conv.messages.append(self.arena, message) catch return null; + if (kind == .user and message.text.len > 0) return titleFromPrompt(self.arena, message.text); + return null; + } + + fn importFollowupLocked(self: *Runtime, conv: *Conversation, value: Value) ?Followup { + if (value != .object) return null; + const followup_id = stringField(value, "followupId") orelse return null; + const question = stringField(value, "question") orelse ""; + var options: std.ArrayList(FollowupOption) = .empty; + if (arrayField(value, "options")) |raw_options| { + for (raw_options.items) |item| { + if (item != .object) continue; + const id = stringField(item, "id") orelse continue; + const label = stringField(item, "label") orelse continue; + options.append(self.arena, .{ .id = self.dupe(id), .label = self.dupe(label) }) catch {}; + } + } + return .{ + .followup_id = self.dupe(followup_id), + .workspace_path = self.dupe(stringField(value, "workspacePath") orelse conv.workspace_path), + .conversation_id = conv.conversation_id, + .request_id = self.dupe(stringField(value, "requestId") orelse ""), + .kind = self.dupe(stringField(value, "kind") orelse "text"), + .question = self.dupe(question), + .options = options.toOwnedSlice(self.arena) catch &.{}, + .call_id = if (stringField(value, "callId")) |call_id| self.dupe(call_id) else self.dupe(followup_id), + }; + } + + fn valueJson(self: *Runtime, value: Value) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(self.arena); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(value); + return out.written(); + } + + fn refreshWorkspaceSessionsLocked(self: *Runtime, force: bool) void { + for (self.workspaces.items) |workspace| { + self.scanWorkspaceSessionsLocked(workspace.path, force); + } + } + + fn renameManagedChatWorkspaceLocked(self: *Runtime, workspace_path: []const u8, title: []const u8) void { + if (self.workspaceIndexLocked(workspace_path)) |idx| { + if (std.mem.eql(u8, self.workspaces.items[idx].kind, "managed_chat")) { + self.workspaces.items[idx].display_name = self.dupe(title); + } + } + } + + fn ensureWorkspaceSelectionLocked(self: *Runtime, workspace_path: []const u8) void { + if (self.selected_by_workspace.get(workspace_path)) |selected| { + if (self.conversations.get(selected)) |conv| { + if (std.mem.eql(u8, conv.workspace_path, workspace_path)) return; + } + } + + var best: ?*Conversation = null; + var it = self.conversations.iterator(); + while (it.next()) |entry| { + const conv = entry.value_ptr.*; + if (!std.mem.eql(u8, conv.workspace_path, workspace_path)) continue; + if (best == null or conv.updated_at > best.?.updated_at) best = conv; + } + if (best) |conv| { + self.selected_by_workspace.put(workspace_path, conv.conversation_id) catch {}; + if (self.active_workspace_path != null and std.mem.eql(u8, self.active_workspace_path.?, workspace_path)) { + self.active_conversation_id = conv.conversation_id; + } + } else { + _ = self.selected_by_workspace.remove(workspace_path); + if (self.active_workspace_path != null and std.mem.eql(u8, self.active_workspace_path.?, workspace_path)) { + self.active_conversation_id = null; + } + } + } + + fn conversationIdForSession(self: *Runtime, workspace_path: []const u8, session_name: []const u8) []const u8 { + if (isGuiChatSessionName(session_name)) return self.dupe(session_name); + const hash = std.hash.Wyhash.hash(0, workspace_path); + return self.fmt("session-{x:0>16}-{s}", .{ hash, self.sanitizeIdPart(session_name) }); + } + + fn sanitizeIdPart(self: *Runtime, value: []const u8) []const u8 { + var out: std.ArrayList(u8) = .empty; + for (value) |c| { + const ok = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or (c >= '0' and c <= '9') or c == '-' or c == '_'; + out.append(self.arena, if (ok) c else '_') catch {}; + } + if (out.items.len == 0) out.appendSlice(self.arena, "session") catch {}; + return out.toOwnedSlice(self.arena) catch self.dupe("session"); + } + + fn ensureConversationSessionFileLocked(self: *Runtime, conv: *Conversation) void { + const path = sessionFilePath(self.allocator, conv.workspace_path, conv.session_name) catch return; + defer self.allocator.free(path); + if (fileExists(path)) return; + self.writeConversationSessionFileLocked(conv); + } + + fn refreshCanonicalSessionEnvelopeLocked(self: *Runtime, conv: *Conversation) void { + if (conv.session_name.len == 0 or std.mem.indexOfAny(u8, conv.session_name, "/\\") != null) return; + const path = sessionFilePath(self.allocator, conv.workspace_path, conv.session_name) catch return; + defer self.allocator.free(path); + var tmp = std.heap.ArenaAllocator.init(self.allocator); + defer tmp.deinit(); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, tmp.allocator(), .limited(8 * 1024 * 1024)) catch return; + const parsed = std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }) catch return; + if (parsed != .object) return; + if (stringField(parsed, "provider")) |provider| conv.session_provider = self.dupe(provider); + if (stringField(parsed, "model")) |model| conv.session_model = self.dupe(model); + conv.session_strict = boolField(parsed, "strict") orelse conv.session_strict; + conv.session_ultracode_mode = boolField(parsed, "ultracode_mode") orelse conv.session_ultracode_mode; + const messages_value = parsed.object.get("messages") orelse return; + if (messages_value != .array) return; + conv.cli_messages_json = self.valueJson(messages_value) catch conv.cli_messages_json; + } + + fn writeConversationSessionFileLocked(self: *Runtime, conv: *Conversation) void { + if (conv.session_name.len == 0 or std.mem.indexOfAny(u8, conv.session_name, "/\\") != null) return; + ensureDirectory(self.allocator, conv.workspace_path) catch {}; + const path = sessionFilePath(self.allocator, conv.workspace_path, conv.session_name) catch return; + defer self.allocator.free(path); + const provider = self.settings.selected_provider orelse conv.session_provider orelse "codegraff"; + const selected_model = self.settings.selected_model orelse conv.session_model orelse "deepseek-v4-pro"; + const model = if (selected_model.len == 0) "deepseek-v4-pro" else selected_model; + const data = self.conversationSessionJsonLocked(conv, provider, model) catch return; + defer self.allocator.free(data); + + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = data }) catch {}; + self.invalidateSessionScanLocked(conv.workspace_path); + } + + fn conversationSessionJsonLocked(self: *Runtime, conv: *Conversation, provider: []const u8, model: []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + const w = &out.writer; + try w.writeAll("{\"provider\":"); + try writeString(w, provider); + try w.writeAll(",\"model\":"); + try writeString(w, model); + try w.writeAll(",\"strict\":"); + try w.writeAll(if (conv.session_strict) "true" else "false"); + try w.writeAll(",\"ultracode_mode\":"); + try w.writeAll(if (conv.session_ultracode_mode) "true" else "false"); + try w.writeAll(",\"goal\":"); + try writeNullableString(w, conv.goal); + try w.writeAll(",\"title\":"); + try writeString(w, conv.title); + try w.writeAll(",\"updated_ms\":"); + try w.print("{d}", .{if (conv.updated_at > 0) conv.updated_at else nowMillis()}); + try w.writeAll(",\"messages\":"); + if (conv.cli_messages_json) |messages_json| { + try w.writeAll(messages_json); + } else { + try w.writeByte('['); + var first = true; + for (conv.messages.items) |message| { + const role: []const u8 = switch (message.kind) { + .user => "user", + .assistant => "assistant", + else => continue, + }; + if (!first) try w.writeByte(','); + first = false; + try w.writeAll("{\"role\":"); + try writeString(w, role); + try w.writeAll(",\"content\":"); + try writeString(w, message.text); + try w.writeAll("}"); + } + try w.writeByte(']'); + } + try w.writeAll(",\"guiMessages\":"); + try self.writeMessages(w, conv.messages.items); + try w.writeAll(",\"followup\":"); + try self.writeFollowup(w, conv.followup); + try w.writeAll("}"); + return try self.allocator.dupe(u8, out.written()); + } + + fn deleteConversationSessionFileLocked(self: *Runtime, conv: *Conversation) void { + const path = sessionFilePath(self.allocator, conv.workspace_path, conv.session_name) catch return; + defer self.allocator.free(path); + std.Io.Dir.cwd().deleteFile(mer_runtime.io, path) catch {}; + self.invalidateSessionScanLocked(conv.workspace_path); + } + + fn loadPromptSettings(self: *Runtime) void { + const path = promptSettingsPath(self.allocator) catch return; + defer self.allocator.free(path); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, self.allocator, .limited(64 * 1024)) catch return; + defer self.allocator.free(data); + const parsed = std.json.parseFromSliceLeaky(Value, self.arena, data, .{ .allocate = .alloc_always }) catch return; + if (parsed != .object) return; + if (stringField(parsed, "selected_provider")) |provider| { + self.settings.selected_provider = self.dupe(provider); + } + if (stringField(parsed, "selected_model")) |model| { + self.settings.selected_model = self.dupe(model); + } + if (stringField(parsed, "selected_effort")) |effort| { + if (validReasoningEffort(effort)) self.settings.selected_effort = self.dupe(effort); + } + if (boolField(parsed, "fast_enabled")) |fast| { + self.settings.fast_enabled = fast; + } + if (objectField(parsed, "model_usage")) |usage_value| { + self.loadModelUsage(usage_value); + } + } + + fn ensurePromptSettingsLoaded(self: *Runtime) void { + self.mutex.lockUncancelable(mer_runtime.io); + defer self.mutex.unlock(mer_runtime.io); + if (self.settings_loaded) return; + self.loadPromptSettings(); + self.settings_loaded = true; + } + + fn savePromptSettingsLocked(self: *Runtime) void { + ensureSettingsDir(self.allocator); + const path = promptSettingsPath(self.allocator) catch return; + defer self.allocator.free(path); + var out: std.Io.Writer.Allocating = .init(self.allocator); + defer out.deinit(); + out.writer.writeAll("{\"selected_provider\":") catch return; + writeNullableString(&out.writer, self.settings.selected_provider) catch return; + out.writer.writeAll(",\"selected_model\":") catch return; + writeNullableString(&out.writer, self.settings.selected_model) catch return; + out.writer.writeAll(",\"selected_effort\":") catch return; + writeNullableString(&out.writer, self.settings.selected_effort) catch return; + out.writer.writeAll(",\"fast_enabled\":") catch return; + out.writer.writeAll(if (self.settings.fast_enabled) "true" else "false") catch return; + out.writer.writeAll(",\"model_usage\":") catch return; + writeModelUsageJson(&out.writer, &self.model_usage) catch return; + out.writer.writeAll("}") catch return; + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = out.written() }) catch {}; + } + + fn modelUsageKey(self: *Runtime, provider_id: []const u8, model_id: []const u8) []const u8 { + return self.fmt("{s}/{s}", .{ provider_id, model_id }); + } + + fn incrementModelUsageLocked(self: *Runtime, provider_id: []const u8, model_id: []const u8, now: i64) void { + const key = self.modelUsageKey(provider_id, model_id); + const result = self.model_usage.getOrPut(key) catch return; + if (result.found_existing) { + result.value_ptr.prompt_count = std.math.add(u64, result.value_ptr.prompt_count, 1) catch std.math.maxInt(u64); + result.value_ptr.last_used_at = now; + } else { + result.key_ptr.* = self.allocator.dupe(u8, key) catch key; + result.value_ptr.* = .{ .prompt_count = 1, .last_used_at = now }; + } + } + + fn recordModelUsage(self: *Runtime, provider_raw: ?[]const u8, model_raw: ?[]const u8) void { + const provider = provider_raw orelse return; + const model = model_raw orelse return; + self.mutex.lockUncancelable(mer_runtime.io); + self.incrementModelUsageLocked(provider, model, nowMillis()); + self.savePromptSettingsLocked(); + self.mutex.unlock(mer_runtime.io); + } + + fn loadModelUsage(self: *Runtime, usage_value: Value) void { + if (usage_value != .object) return; + var provider_it = usage_value.object.iterator(); + while (provider_it.next()) |provider_entry| { + const provider_id = provider_entry.key_ptr.*; + const models_value = provider_entry.value_ptr.*; + if (models_value != .object) continue; + var model_it = models_value.object.iterator(); + while (model_it.next()) |model_entry| { + const model_id = model_entry.key_ptr.*; + const entry_value = model_entry.value_ptr.*; + if (entry_value != .object) continue; + const count_value = entry_value.object.get("prompt_count") orelse continue; + const count: u64 = switch (count_value) { + .integer => |v| if (v > 0) @intCast(v) else 0, + else => 0, + }; + const last_value = entry_value.object.get("last_used_at") orelse null; + const last: i64 = if (last_value) |v| switch (v) { + .integer => |n| n, + else => 0, + } else 0; + if (count == 0) continue; + const key = self.modelUsageKey(provider_id, model_id); + const owned = self.allocator.dupe(u8, key) catch continue; + self.model_usage.put(owned, .{ .prompt_count = count, .last_used_at = last }) catch self.allocator.free(owned); + } + } + } + + fn bumpLocked(self: *Runtime) void { + _ = self.version.fetchAdd(1, .release); + } + + fn dupe(self: *Runtime, s: []const u8) []const u8 { + return self.arena.dupe(u8, s) catch @panic("oom"); + } + + fn fmt(self: *Runtime, comptime format: []const u8, args: anytype) []const u8 { + return std.fmt.allocPrint(self.arena, format, args) catch @panic("oom"); + } + + fn uniqueId(self: *Runtime, prefix: []const u8) []const u8 { + var raw: [8]u8 = undefined; + mer_runtime.io.random(&raw); + return self.fmt("{s}-{x:0>16}", .{ prefix, std.mem.readInt(u64, &raw, .big) }); + } +}; + +fn graffTurnThread(self: *Runtime, conversation_id: []const u8, request_id: []const u8, session_name: []const u8, workspace: []const u8, prompt: []const u8, agent_id: []const u8, plan_mode: bool) void { + self.streamGraffTurn(conversation_id, request_id, session_name, workspace, prompt, agent_id, plan_mode, true) catch |err| { + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(conversation_id)) |conv| { + if (self.requestActiveLocked(conversation_id, request_id)) { + conv.messages.append(self.arena, .{ + .kind = .@"error", + .id = self.fmt("{s}-error", .{request_id}), + .request_id = request_id, + .error_message = self.fmt("Failed to run graff: {s}", .{@errorName(err)}), + }) catch {}; + self.writeConversationSessionFileLocked(conv); + } + } + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); + }; + + self.mutex.lockUncancelable(mer_runtime.io); + if (self.conversations.get(conversation_id)) |conv| { + const was_active = self.requestActiveLocked(conversation_id, request_id); + removeString(&conv.active_request_ids, request_id); + conv.followup = null; + self.refreshCanonicalSessionEnvelopeLocked(conv); + self.writeConversationSessionFileLocked(conv); + if (was_active) self.emitRequestEventLocked("request-finished", conv, request_id); + } + self.bumpLocked(); + self.mutex.unlock(mer_runtime.io); +} + +fn commandName(path: []const u8) []const u8 { + if (std.mem.startsWith(u8, path, "/api/")) return path[5..]; + return path; +} + +fn parse(req: mer.Request) !Value { + const body = if (req.body.len == 0) "{}" else req.body; + return std.json.parseFromSliceLeaky(Value, req.allocator, body, .{}); +} + +fn stringField(v: Value, key: []const u8) ?[]const u8 { + if (v != .object) return null; + const item = v.object.get(key) orelse return null; + return switch (item) { + .string => |s| s, + else => null, + }; +} + +fn boolField(v: Value, key: []const u8) ?bool { + if (v != .object) return null; + const item = v.object.get(key) orelse return null; + return switch (item) { + .bool => |b| b, + else => null, + }; +} + +fn optionalStringEql(a: ?[]const u8, b: ?[]const u8) bool { + if (a) |aa| { + if (b) |bb| return std.mem.eql(u8, aa, bb); + return false; + } + return b == null; +} + +fn intField(v: Value, key: []const u8, default: i64) i64 { + if (v != .object) return default; + const item = v.object.get(key) orelse return default; + return switch (item) { + .integer => |n| n, + .float => |n| @intFromFloat(n), + else => default, + }; +} + +fn objectField(v: Value, key: []const u8) ?Value { + if (v != .object) return null; + const item = v.object.get(key) orelse return null; + return if (item == .object) item else null; +} + +fn arrayField(v: Value, key: []const u8) ?std.json.Array { + if (v != .object) return null; + const item = v.object.get(key) orelse return null; + return if (item == .array) item.array else null; +} + +fn hasParentPathComponent(path: []const u8) bool { + var parts = std.mem.tokenizeAny(u8, path, "/\\"); + while (parts.next()) |part| { + if (std.mem.eql(u8, part, "..")) return true; + } + return false; +} + +fn pathIsWithinRoot(root: []const u8, target: []const u8) bool { + if (std.mem.eql(u8, root, target)) return true; + if (!std.mem.startsWith(u8, target, root)) return false; + if (root.len == 0 or target.len <= root.len) return false; + return target[root.len] == '/'; +} + +fn stripDataUrlBase64(value: []const u8) []const u8 { + if (!std.mem.startsWith(u8, value, "data:")) return value; + const comma = std.mem.indexOfScalar(u8, value, ',') orelse return value; + return value[comma + 1 ..]; +} + +fn isSafeAttachmentNameByte(c: u8) bool { + return (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '.' or + c == '-' or + c == '_'; +} + +fn safeAttachmentFileName(alloc: std.mem.Allocator, name: []const u8) ![]const u8 { + var start: usize = 0; + if (std.mem.lastIndexOfScalar(u8, name, '/')) |idx| start = @max(start, idx + 1); + if (std.mem.lastIndexOfScalar(u8, name, '\\')) |idx| start = @max(start, idx + 1); + const base = name[start..]; + + var out: std.ArrayList(u8) = .empty; + for (base) |c| { + if (out.items.len >= 180) break; + try out.append(alloc, if (isSafeAttachmentNameByte(c)) c else '_'); + } + + const result = std.mem.trim(u8, out.items, "._-"); + if (result.len == 0) return try alloc.dupe(u8, "attachment"); + return try alloc.dupe(u8, result); +} + +fn joinArgs(alloc: std.mem.Allocator, v: Value) ![]const u8 { + const args = arrayField(v, "args") orelse return ""; + var out: std.Io.Writer.Allocating = .init(alloc); + for (args.items, 0..) |item, idx| { + if (item != .string) continue; + if (idx > 0 and out.written().len > 0) try out.writer.writeByte(' '); + try out.writer.writeAll(item.string); + } + return out.written(); +} + +fn commandText(req: mer.Request, title: []const u8, body: []const u8) mer.Response { + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"title\":") catch return oom(); + writeString(&out.writer, title) catch return oom(); + out.writer.writeAll(",\"body\":") catch return oom(); + writeString(&out.writer, body) catch return oom(); + out.writer.writeAll(",\"snapshot\":null,\"savedPath\":null,\"resultKind\":\"text\",\"payload\":null}") catch return oom(); + return mer.json(out.written()); +} + +fn writeString(w: *std.Io.Writer, s: []const u8) !void { + var jw: std.json.Stringify = .{ .writer = w }; + try jw.write(s); +} + +fn writeNullableString(w: *std.Io.Writer, s: ?[]const u8) !void { + if (s) |value| try writeString(w, value) else try w.writeAll("null"); +} + +fn writeStringArray(w: *std.Io.Writer, values: []const []const u8) !void { + try w.writeByte('['); + for (values, 0..) |value, i| { + if (i > 0) try w.writeByte(','); + try writeString(w, value); + } + try w.writeByte(']'); +} + +fn selectedOptionLabel(options: []const FollowupOption, id: []const u8) ?[]const u8 { + for (options) |option| { + if (std.mem.eql(u8, option.id, id)) return option.label; + } + return null; +} + +fn answerText(alloc: std.mem.Allocator, followup: Followup, notes_raw: []const u8, selected_ids: ?std.json.Array) ![]const u8 { + const notes = std.mem.trim(u8, notes_raw, " \t\r\n"); + var out: std.Io.Writer.Allocating = .init(alloc); + if (selected_ids) |ids| { + var wrote_option = false; + for (ids.items) |item| { + if (item != .string) continue; + const label = selectedOptionLabel(followup.options, item.string) orelse continue; + if (wrote_option) try out.writer.writeByte('\n'); + try out.writer.writeAll(label); + wrote_option = true; + } + if (wrote_option and notes.len > 0) try out.writer.writeAll("\n\nNotes: "); + } + if (notes.len > 0) try out.writer.writeAll(notes); + return out.toOwnedSlice(); +} + +fn answerLineJson(alloc: std.mem.Allocator, followup: Followup, notes: []const u8, selected_ids: ?std.json.Array, cancelled: bool) ![]const u8 { + const text = if (cancelled) "" else try answerText(alloc, followup, notes, selected_ids); + defer if (!cancelled) alloc.free(text); + var out: std.Io.Writer.Allocating = .init(alloc); + try out.writer.writeAll("{\"type\":\"answer\",\"text\":"); + try writeString(&out.writer, text); + try out.writer.writeAll(",\"cancelled\":"); + try out.writer.writeAll(if (cancelled) "true" else "false"); + try out.writer.writeAll(",\"call_id\":"); + try writeString(&out.writer, followup.call_id orelse followup.followup_id); + try out.writer.writeByte('}'); + return out.toOwnedSlice(); +} + +fn writeAnswerLine(child: *std.process.Child, line: []const u8) !void { + var wbuf: [4096]u8 = undefined; + var cw = child.stdin.?.writerStreaming(mer_runtime.io, &wbuf); + try cw.interface.writeAll(line); + try cw.interface.writeByte('\n'); + try cw.interface.flush(); +} + +const PromptSelection = struct { + provider: ?[]const u8, + model: ?[]const u8, +}; + +const TurnPromptSelection = struct { + provider: ?[]const u8, + model: ?[]const u8, + effort: ?[]const u8, + fast_enabled: bool, + send_model_control: bool, +}; + +fn schemaValue(alloc: std.mem.Allocator, graff_bin: []const u8) !Value { + const raw = try commandOutput(alloc, &.{ graff_bin, "--schema" }); + return try std.json.parseFromSliceLeaky(Value, alloc, raw, .{ .allocate = .alloc_always }); +} + +const PromptModelListItem = struct { + provider_id: []const u8, + provider_name: []const u8, + model_id: []const u8, + context_length: ?i64, + supports_reasoning: bool, + usage_count: u64, +}; + +fn modelUsageKeyAlloc(alloc: std.mem.Allocator, provider_id: []const u8, model_id: []const u8) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/{s}", .{ provider_id, model_id }); +} + +fn modelUsageCount(alloc: std.mem.Allocator, usage: *const std.StringHashMap(ModelUsage), provider_id: []const u8, model_id: []const u8) u64 { + const key = modelUsageKeyAlloc(alloc, provider_id, model_id) catch return 0; + defer alloc.free(key); + return if (usage.get(key)) |entry| entry.prompt_count else 0; +} + +fn modelSortLessThan(_: void, left: PromptModelListItem, right: PromptModelListItem) bool { + if (left.usage_count != right.usage_count) return left.usage_count > right.usage_count; + const by_model = std.ascii.orderIgnoreCase(left.model_id, right.model_id); + if (by_model != .eq) return by_model == .lt; + const by_provider_name = std.ascii.orderIgnoreCase(left.provider_name, right.provider_name); + if (by_provider_name != .eq) return by_provider_name == .lt; + return std.ascii.orderIgnoreCase(left.provider_id, right.provider_id) == .lt; +} + +fn writePromptModels(alloc: std.mem.Allocator, w: *std.Io.Writer, schema: ?Value, usage: *const std.StringHashMap(ModelUsage)) !void { + const s = schema orelse { + try w.writeAll("[{\"providerId\":\"codegraff\",\"providerName\":\"Codegraff\",\"modelId\":\"default\",\"modelName\":\"Default\",\"contextLength\":null,\"supportsReasoning\":false,\"reasoningEfforts\":[]}]"); + return; + }; + const providers = arrayField(s, "providers") orelse { + try w.writeAll("[]"); + return; + }; + const models = arrayField(s, "models") orelse { + try w.writeAll("[]"); + return; + }; + + var items: std.ArrayList(PromptModelListItem) = .empty; + defer items.deinit(alloc); + + for (providers.items) |provider_value| { + if (provider_value != .object) continue; + const provider_id = strFieldObj(provider_value.object, "id") orelse continue; + const env_key = strFieldObj(provider_value.object, "env_key"); + if (!providerConfiguredById(provider_id, env_key)) continue; + const provider_name = strFieldObj(provider_value.object, "name") orelse provider_id; + for (models.items) |model_value| { + if (model_value != .object) continue; + const model_provider = strFieldObj(model_value.object, "provider") orelse continue; + if (!std.mem.eql(u8, model_provider, provider_id)) continue; + const model_name = strFieldObj(model_value.object, "name") orelse continue; + const context_length: ?i64 = if (model_value.object.get("context")) |context| switch (context) { + .integer => |value| value, + else => null, + } else null; + try items.append(alloc, .{ + .provider_id = provider_id, + .provider_name = provider_name, + .model_id = model_name, + .context_length = context_length, + .supports_reasoning = promptModelSupportsReasoning(provider_id, model_name), + .usage_count = modelUsageCount(alloc, usage, provider_id, model_name), + }); + } + } + + std.mem.sort(PromptModelListItem, items.items, {}, modelSortLessThan); + + try w.writeByte('['); + for (items.items, 0..) |item, index| { + if (index > 0) try w.writeByte(','); + try w.writeAll("{\"providerId\":"); + try writeString(w, item.provider_id); + try w.writeAll(",\"providerName\":"); + try writeString(w, item.provider_name); + try w.writeAll(",\"modelId\":"); + try writeString(w, item.model_id); + try w.writeAll(",\"modelName\":"); + try writeString(w, item.model_id); + try w.writeAll(",\"contextLength\":"); + if (item.context_length) |context| try w.print("{d}", .{context}) else try w.writeAll("null"); + try w.writeAll(",\"supportsReasoning\":"); + try w.writeAll(if (item.supports_reasoning) "true" else "false"); + try w.writeAll(",\"reasoningEfforts\":"); + try w.writeAll(if (item.supports_reasoning) "[\"low\",\"medium\",\"high\"]" else "[]"); + try w.writeAll("}"); + } + try w.writeByte(']'); +} + +fn writeModelUsageJson(w: *std.Io.Writer, usage: *const std.StringHashMap(ModelUsage)) !void { + try w.writeByte('{'); + var provider_first = true; + var providers = std.StringHashMap(void).init(std.heap.page_allocator); + defer providers.deinit(); + var key_it = usage.keyIterator(); + while (key_it.next()) |key_ptr| { + const key = key_ptr.*; + const slash = std.mem.indexOfScalar(u8, key, '/') orelse continue; + providers.put(key[0..slash], {}) catch {}; + } + var provider_it = providers.keyIterator(); + while (provider_it.next()) |provider_ptr| { + const provider_id = provider_ptr.*; + if (!provider_first) try w.writeByte(','); + provider_first = false; + try writeString(w, provider_id); + try w.writeAll(":{"); + var model_first = true; + var usage_it = usage.iterator(); + while (usage_it.next()) |entry| { + const key = entry.key_ptr.*; + const slash = std.mem.indexOfScalar(u8, key, '/') orelse continue; + if (!std.mem.eql(u8, key[0..slash], provider_id)) continue; + const model_id = key[slash + 1 ..]; + if (!model_first) try w.writeByte(','); + model_first = false; + try writeString(w, model_id); + try w.print(":{{\"prompt_count\":{d},\"last_used_at\":{d}}}", .{ entry.value_ptr.prompt_count, entry.value_ptr.last_used_at }); + } + try w.writeByte('}'); + } + try w.writeByte('}'); +} + +fn resolvePromptSelection(schema: ?Value, selected_provider: ?[]const u8, selected_model: ?[]const u8) PromptSelection { + const s = schema orelse return .{ .provider = selected_provider orelse "codegraff", .model = selected_model orelse "default" }; + if (selected_provider) |provider| { + const model = selected_model orelse providerDefaultModel(s, provider); + if (model) |m| { + if (configuredModelExists(s, provider, m)) return .{ .provider = provider, .model = m }; + } + } + if (firstConfiguredProviderSelection(s, "codegraff")) |sel| return sel; + const providers = arrayField(s, "providers") orelse return .{ .provider = "codegraff", .model = "default" }; + for (providers.items) |provider_value| { + if (provider_value != .object) continue; + const provider_id = strFieldObj(provider_value.object, "id") orelse continue; + if (firstConfiguredProviderSelection(s, provider_id)) |sel| return sel; + } + return .{ .provider = "codegraff", .model = "default" }; +} + +fn firstConfiguredProviderSelection(schema: Value, provider_id: []const u8) ?PromptSelection { + const env_key = schemaProviderEnvKey(schema, provider_id); + if (!providerConfiguredById(provider_id, env_key)) return null; + if (providerDefaultModel(schema, provider_id)) |default_model| { + if (modelExists(schema, provider_id, default_model)) return .{ .provider = provider_id, .model = default_model }; + } + const models = arrayField(schema, "models") orelse return null; + for (models.items) |model_value| { + if (model_value != .object) continue; + const model_provider = strFieldObj(model_value.object, "provider") orelse continue; + if (!std.mem.eql(u8, model_provider, provider_id)) continue; + const model_name = strFieldObj(model_value.object, "name") orelse continue; + return .{ .provider = provider_id, .model = model_name }; + } + return null; +} + +fn configuredModelExists(schema: Value, provider_id: []const u8, model: []const u8) bool { + const env_key = schemaProviderEnvKey(schema, provider_id); + return providerConfiguredById(provider_id, env_key) and modelExists(schema, provider_id, model); +} + +fn modelExists(schema: Value, provider_id: []const u8, model: []const u8) bool { + const models = arrayField(schema, "models") orelse return false; + for (models.items) |model_value| { + if (model_value != .object) continue; + const model_provider = strFieldObj(model_value.object, "provider") orelse continue; + const model_name = strFieldObj(model_value.object, "name") orelse continue; + if (std.mem.eql(u8, model_provider, provider_id) and std.mem.eql(u8, model_name, model)) return true; + } + return false; +} + +fn providerDefaultModel(schema: Value, provider_id: []const u8) ?[]const u8 { + const providers = arrayField(schema, "providers") orelse return null; + for (providers.items) |provider_value| { + if (provider_value != .object) continue; + const id = strFieldObj(provider_value.object, "id") orelse continue; + if (std.mem.eql(u8, id, provider_id)) return strFieldObj(provider_value.object, "default_model"); + } + return null; +} + +fn schemaProviderEnvKey(schema: Value, provider_id: []const u8) ?[]const u8 { + const providers = arrayField(schema, "providers") orelse return providerInfo(provider_id).env_key; + for (providers.items) |provider_value| { + if (provider_value != .object) continue; + const id = strFieldObj(provider_value.object, "id") orelse continue; + if (std.mem.eql(u8, id, provider_id)) return strFieldObj(provider_value.object, "env_key"); + } + return providerInfo(provider_id).env_key; +} + +fn promptModelSupportsReasoning(provider_id: []const u8, model: []const u8) bool { + if (std.mem.startsWith(u8, model, "grok")) return false; + return std.mem.eql(u8, provider_id, "codegraff") or + std.mem.eql(u8, provider_id, "deepseek") or + std.mem.eql(u8, provider_id, "kimi") or + std.mem.eql(u8, provider_id, "codex"); +} + +fn clampTerminalSize(value: i64, default: u16) u16 { + if (value < 20 or value > 500) return default; + return @intCast(value); +} + +fn terminalSessionJson(alloc: std.mem.Allocator, terminal_id: []const u8, instance_id: []const u8, workspace: []const u8, shell: []const u8, cols: u16, rows: u16, scrollback: []const u8, scrollback_truncated: bool, scrollback_seq: u64) mer.Response { + var out: std.Io.Writer.Allocating = .init(alloc); + out.writer.writeAll("{\"terminalId\":") catch return oom(); + writeString(&out.writer, terminal_id) catch return oom(); + out.writer.writeAll(",\"terminalInstanceId\":") catch return oom(); + writeString(&out.writer, instance_id) catch return oom(); + out.writer.writeAll(",\"workspacePath\":") catch return oom(); + writeString(&out.writer, workspace) catch return oom(); + out.writer.writeAll(",\"shell\":") catch return oom(); + writeString(&out.writer, shell) catch return oom(); + out.writer.writeAll(",\"cols\":") catch return oom(); + out.writer.print("{d}", .{cols}) catch return oom(); + out.writer.writeAll(",\"rows\":") catch return oom(); + out.writer.print("{d}", .{rows}) catch return oom(); + out.writer.writeAll(",\"scrollback\":") catch return oom(); + writeString(&out.writer, scrollback) catch return oom(); + out.writer.writeAll(",\"scrollbackTruncated\":") catch return oom(); + out.writer.writeAll(if (scrollback_truncated) "true" else "false") catch return oom(); + out.writer.writeAll(",\"scrollbackSeq\":") catch return oom(); + out.writer.print("{d}", .{scrollback_seq}) catch return oom(); + out.writer.writeAll("}") catch return oom(); + return mer.json(out.written()); +} + +fn resolveShell() []const u8 { + if (std.c.getenv("SHELL")) |ptr| { + const shell = std.mem.span(ptr); + if (shell.len > 0 and std.mem.startsWith(u8, shell, "/")) return shell; + } + if (builtin.os.tag == .macos) return "/bin/zsh"; + return "/bin/sh"; +} + +fn closeTerminalSession(session: *TerminalSessionState) void { + if (session.closing.swap(true, .acq_rel)) return; + session.io_mutex.lockUncancelable(mer_runtime.io); + defer session.io_mutex.unlock(mer_runtime.io); + session.proc.terminate(); + session.proc.close(); +} + +fn terminalReaderMain(rt: *Runtime, session: *TerminalSessionState) void { + var buffer: [8192]u8 = undefined; + while (!session.closing.load(.acquire)) { + const n = session.proc.read(&buffer) catch break; + if (n == 0) break; + if (sanitizeTerminalChunk(rt.allocator, session, buffer[0..n])) |chunk| { + defer rt.allocator.free(chunk); + if (chunk.len > 0) rt.emitTerminalOutput(session, chunk); + } else |_| rt.emitTerminalOutput(session, buffer[0..n]); + } + + const exit_code = session.proc.wait() orelse 0; + if (session.utf8_pending_len > 0) { + session.utf8_pending_len = 0; + rt.emitTerminalOutput(session, "�"); + } + if (!session.exited.swap(true, .acq_rel)) { + rt.emitTerminalExit(session, exit_code); + } + rt.mutex.lockUncancelable(mer_runtime.io); + if (rt.terminals.get(session.terminal_id) == session) { + _ = rt.terminals.remove(session.terminal_id); + } + rt.mutex.unlock(mer_runtime.io); +} + +fn sanitizeTerminalChunk(alloc: std.mem.Allocator, session: *TerminalSessionState, bytes: []const u8) ![]const u8 { + var combined: [8196]u8 = undefined; + var len = session.utf8_pending_len; + if (len > 0) @memcpy(combined[0..len], session.utf8_pending[0..len]); + const copy_len = @min(bytes.len, combined.len - len); + @memcpy(combined[len .. len + copy_len], bytes[0..copy_len]); + len += copy_len; + session.utf8_pending_len = 0; + + var complete_len = len; + var i: usize = 0; + while (i < len) { + const seq_len = std.unicode.utf8ByteSequenceLength(combined[i]) catch { + i += 1; + continue; + }; + if (i + seq_len > len) { + complete_len = i; + const pending_len = len - i; + if (pending_len <= session.utf8_pending.len) { + @memcpy(session.utf8_pending[0..pending_len], combined[i..len]); + session.utf8_pending_len = pending_len; + } + break; + } + i += seq_len; + } + return sanitizeTerminalOutput(alloc, combined[0..complete_len]); +} + +fn sanitizeTerminalOutput(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { + if (std.unicode.utf8ValidateSlice(bytes)) return try alloc.dupe(u8, bytes); + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(alloc); + var i: usize = 0; + while (i < bytes.len) { + const len = std.unicode.utf8ByteSequenceLength(bytes[i]) catch { + try out.appendSlice(alloc, "�"); + i += 1; + continue; + }; + if (i + len <= bytes.len and std.unicode.utf8ValidateSlice(bytes[i .. i + len])) { + try out.appendSlice(alloc, bytes[i .. i + len]); + i += len; + } else { + try out.appendSlice(alloc, "�"); + i += 1; + } + } + return out.toOwnedSlice(alloc); +} + +fn guiAgentReadOnly(agent_id: []const u8) bool { + return std.mem.eql(u8, agent_id, "muse") or std.mem.eql(u8, agent_id, "sage"); +} + +fn guiAgentCoreAgent(agent_id: []const u8) ?[]const u8 { + if (std.mem.eql(u8, agent_id, "muse") or std.mem.eql(u8, agent_id, "sage")) return "researcher"; + return null; +} + +fn isProtocolAckEvent(ty: []const u8) bool { + inline for (.{ "system_prompt", "model", "compact", "mode", "agent", "effort", "fast", "ultracode", "score" }) |name| { + if (std.mem.eql(u8, ty, name)) return true; + } + return false; +} + +fn validReasoningEffort(level: []const u8) bool { + return std.mem.eql(u8, level, "low") or + std.mem.eql(u8, level, "medium") or + std.mem.eql(u8, level, "high"); +} + +fn effectiveReasoningEffort(provider: ?[]const u8, model: ?[]const u8, selected: ?[]const u8) ?[]const u8 { + if (selected) |level| { + if (validReasoningEffort(level)) return level; + } + const provider_id = provider orelse return null; + const model_id = model orelse return null; + if (!promptModelSupportsReasoning(provider_id, model_id)) return null; + return default_reasoning_effort; +} + +fn isDefaultConversationTitle(title: []const u8) bool { + return title.len == 0 or + std.mem.eql(u8, title, "New chat") or + std.mem.eql(u8, title, "Untitled session"); +} + +fn shouldAutoTitleConversation(conv: *const Conversation) bool { + if (!isDefaultConversationTitle(conv.title)) return false; + for (conv.messages.items) |message| { + if (message.kind == .user) return false; + } + return true; +} + +fn titleFromPrompt(alloc: std.mem.Allocator, prompt: []const u8) []const u8 { + var words = std.mem.tokenizeAny(u8, prompt, " \t\r\n"); + var out: std.Io.Writer.Allocating = .init(alloc); + var n: usize = 0; + while (words.next()) |word| { + if (n >= 8) break; + if (n > 0) out.writer.writeByte(' ') catch break; + out.writer.writeAll(word) catch break; + n += 1; + } + if (out.written().len == 0) return alloc.dupe(u8, "New chat") catch "New chat"; + return out.written(); +} + +fn workspaceName(path: []const u8) []const u8 { + return std.fs.path.basename(path); +} + +const GitRuntimeStatus = struct { + root_path: ?[]const u8 = null, + repo_name: ?[]const u8 = null, + branch_name: ?[]const u8 = null, + workspace_kind: ?[]const u8 = null, + branches: []const []const u8 = &.{}, +}; + +const GitFileStatus = struct { + path: []const u8, + status: []const u8, +}; + +fn renderRuntimeStatusJson(alloc: std.mem.Allocator, p: []const u8) ![]const u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + const git = gitRuntimeStatus(alloc, p); + try out.writer.writeAll("{\"workspacePath\":"); + try writeNullableString(&out.writer, if (p.len == 0) null else p); + try out.writer.writeAll(",\"workspaceName\":"); + try writeNullableString(&out.writer, if (p.len == 0) null else workspaceName(p)); + try out.writer.writeAll(",\"gitRepoName\":"); + try writeNullableString(&out.writer, git.repo_name); + try out.writer.writeAll(",\"gitBranchName\":"); + try writeNullableString(&out.writer, git.branch_name); + try out.writer.writeAll(",\"gitBranches\":"); + try writeStringArray(&out.writer, git.branches); + try out.writer.writeAll(",\"gitWorkspaceKind\":"); + try writeNullableString(&out.writer, git.workspace_kind); + try out.writer.writeAll(",\"gitMainWorkspacePath\":"); + try writeNullableString(&out.writer, git.root_path); + try out.writer.writeAll(",\"availableOpenTargets\":[\"file-manager\"],\"configured\":true,\"configurationError\":null}"); + return out.written(); +} + +fn gitRuntimeStatus(alloc: std.mem.Allocator, workspace: []const u8) GitRuntimeStatus { + if (workspace.len == 0) return .{}; + const root_raw = gitOutput(alloc, workspace, &.{ "git", "rev-parse", "--show-toplevel" }) catch return .{}; + const root = std.mem.trim(u8, root_raw, " \t\r\n"); + if (root.len == 0) return .{}; + const branch_raw = gitOutput(alloc, workspace, &.{ "git", "branch", "--show-current" }) catch ""; + const branch = std.mem.trim(u8, branch_raw, " \t\r\n"); + const common_raw = gitOutput(alloc, workspace, &.{ "git", "rev-parse", "--git-common-dir" }) catch ""; + const common = std.mem.trim(u8, common_raw, " \t\r\n"); + const is_linked_worktree = common.len > 0 and !std.mem.eql(u8, common, ".git"); + const main_root = if (is_linked_worktree and std.mem.eql(u8, std.fs.path.basename(common), ".git")) + (std.fs.path.dirname(common) orelse root) + else + root; + return .{ + .root_path = alloc.dupe(u8, main_root) catch main_root, + .repo_name = alloc.dupe(u8, workspaceName(main_root)) catch workspaceName(main_root), + .branch_name = if (branch.len == 0) null else alloc.dupe(u8, branch) catch branch, + .workspace_kind = if (is_linked_worktree) "worktree" else "local", + .branches = gitBranches(alloc, workspace) catch &.{}, + }; +} + +fn worktreePathForBranch(alloc: std.mem.Allocator, main_workspace: []const u8, branch: []const u8) ![]const u8 { + const parent = std.fs.path.dirname(main_workspace) orelse "."; + const base = workspaceName(main_workspace); + var safe: std.ArrayList(u8) = .empty; + defer safe.deinit(alloc); + for (branch) |ch| { + try safe.append(alloc, if (std.ascii.isAlphanumeric(ch) or ch == '-' or ch == '_') ch else '-'); + } + if (safe.items.len == 0) try safe.appendSlice(alloc, "branch"); + const dirname = try std.fmt.allocPrint(alloc, "{s}-{s}", .{ base, safe.items }); + return std.fs.path.join(alloc, &.{ parent, dirname }); +} + +fn gitBranches(alloc: std.mem.Allocator, workspace: []const u8) ![]const []const u8 { + const raw = try gitOutput(alloc, workspace, &.{ "git", "branch", "--format=%(refname:short)" }); + var list: std.ArrayList([]const u8) = .empty; + var lines = std.mem.splitScalar(u8, raw, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len == 0) continue; + try list.append(alloc, try alloc.dupe(u8, trimmed)); + } + return list.toOwnedSlice(alloc); +} + +fn gitStatusFiles(alloc: std.mem.Allocator, workspace: []const u8) ![]const GitFileStatus { + if (workspace.len == 0) return &.{}; + const raw = try gitOutput(alloc, workspace, &.{ "git", "status", "--short" }); + var list: std.ArrayList(GitFileStatus) = .empty; + var lines = std.mem.splitScalar(u8, raw, '\n'); + while (lines.next()) |line| { + if (line.len < 4) continue; + const status = std.mem.trim(u8, line[0..2], " \t\r\n"); + const path_part = std.mem.trim(u8, line[3..], " \t\r\n"); + if (path_part.len == 0) continue; + const display_path = if (std.mem.lastIndexOf(u8, path_part, " -> ")) |idx| path_part[idx + 4 ..] else path_part; + if (std.mem.eql(u8, display_path, ".claude/") or std.mem.startsWith(u8, display_path, ".claude/")) continue; + try list.append(alloc, .{ + .path = try alloc.dupe(u8, display_path), + .status = try alloc.dupe(u8, if (status.len == 0) "modified" else gitStatusLabel(status)), + }); + } + return list.toOwnedSlice(alloc); +} + +fn runGitMutation(alloc: std.mem.Allocator, workspace: []const u8, cmd: []const u8, input: Value) !void { + if (std.mem.eql(u8, cmd, "checkout_git_branch")) { + const branch = validatedGitBranchName(input) catch |err| return err; + _ = try gitOutput(alloc, workspace, &.{ "git", "switch", branch }); + return; + } + if (std.mem.eql(u8, cmd, "create_git_branch")) { + const branch = validatedGitBranchName(input) catch |err| return err; + _ = try gitOutput(alloc, workspace, &.{ "git", "switch", "-c", branch }); + return; + } + if (std.mem.eql(u8, cmd, "commit_git_changes")) { + const message = stringField(input, "message") orelse return error.MissingCommitMessage; + if (std.mem.trim(u8, message, " \t\r\n").len == 0) return error.MissingCommitMessage; + return error.CommitRequiresExplicitStaging; + } + if (std.mem.eql(u8, cmd, "push_git_branch")) { + const git = gitRuntimeStatus(alloc, workspace); + const branch = git.branch_name orelse return error.MissingBranchName; + if (!isSafeGitBranchName(branch)) return error.InvalidBranchName; + const refspec = try std.fmt.allocPrint(alloc, "refs/heads/{s}:refs/heads/{s}", .{ branch, branch }); + _ = try gitOutput(alloc, workspace, &.{ "git", "push", "-u", "origin", refspec }); + return; + } + return error.UnsupportedGitMutation; +} + +fn validatedGitBranchName(input: Value) ![]const u8 { + const branch = stringField(input, "branchName") orelse return error.MissingBranchName; + if (!isSafeGitBranchName(branch)) return error.InvalidBranchName; + return branch; +} + +fn isSafeGitBranchName(branch: []const u8) bool { + if (branch.len == 0 or branch[0] == '-' or branch[0] == '/' or branch[branch.len - 1] == '/' or branch[branch.len - 1] == '.') return false; + if (std.mem.startsWith(u8, branch, ".") or std.mem.endsWith(u8, branch, ".lock")) return false; + if (std.mem.indexOf(u8, branch, "..") != null or std.mem.indexOf(u8, branch, "@{") != null or std.mem.indexOf(u8, branch, "//") != null) return false; + for (branch) |c| { + const ok = (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '/' or c == '-' or c == '_' or c == '.'; + if (!ok) return false; + } + return true; +} + +fn gitMutationErrorMessage(err: anyerror) []const u8 { + return switch (err) { + error.MissingBranchName => "missing branchName", + error.InvalidBranchName => "invalid branchName", + error.MissingCommitMessage => "missing commit message", + error.CommitRequiresExplicitStaging => "committing from the GUI backend is disabled until explicit reviewed-file staging is implemented", + error.UnsupportedGitMutation => "unsupported git operation", + else => "git operation failed", + }; +} + +fn gitStatusLabel(status: []const u8) []const u8 { + if (std.mem.indexOfScalar(u8, status, '?') != null) return "untracked"; + if (std.mem.indexOfScalar(u8, status, 'A') != null) return "added"; + if (std.mem.indexOfScalar(u8, status, 'D') != null) return "deleted"; + if (std.mem.indexOfScalar(u8, status, 'R') != null) return "renamed"; + if (std.mem.indexOfScalar(u8, status, 'C') != null) return "copied"; + if (std.mem.indexOfScalar(u8, status, 'U') != null) return "conflict"; + return "modified"; +} + +fn gitOutput(alloc: std.mem.Allocator, cwd: []const u8, argv: []const []const u8) ![]const u8 { + var resolved_argv: std.ArrayList([]const u8) = .empty; + defer resolved_argv.deinit(alloc); + try resolved_argv.append(alloc, "/usr/bin/env"); + try resolved_argv.append(alloc, try std.fmt.allocPrint(alloc, "HOME={s}", .{homeDir()})); + try resolved_argv.append(alloc, try std.fmt.allocPrint(alloc, "XDG_CONFIG_HOME={s}/.config", .{homeDir()})); + try resolved_argv.append(alloc, "/usr/bin/git"); + const tail = if (argv.len > 0 and std.mem.eql(u8, argv[0], "git")) argv[1..] else argv; + for (tail) |arg| try resolved_argv.append(alloc, arg); + var child = try std.process.spawn(mer_runtime.io, .{ + .argv = resolved_argv.items, + .cwd = .{ .path = cwd }, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .ignore, + }); + const out_file = child.stdout orelse return error.GitFailed; + var rbuf: [16 * 1024]u8 = undefined; + var reader = out_file.readerStreaming(mer_runtime.io, &rbuf); + const output = try reader.interface.allocRemaining(alloc, .limited(512 * 1024)); + const term = try child.wait(mer_runtime.io); + if (term != .exited or term.exited != 0) return error.GitFailed; + return output; +} + +fn nowMillis() i64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.REALTIME, &ts); + return @as(i64, @intCast(ts.sec)) * 1000 + @as(i64, @intCast(@divTrunc(ts.nsec, 1_000_000))); +} + +fn nowSeconds() i64 { + return @divTrunc(nowMillis(), 1000); +} + +fn firstLine(alloc: std.mem.Allocator, text: []const u8) ?[]const u8 { + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r\n"); + if (trimmed.len > 0) return alloc.dupe(u8, trimmed[0..@min(trimmed.len, 160)]) catch null; + } + return null; +} + +fn removeString(list: *std.ArrayList([]const u8), value: []const u8) void { + var i: usize = 0; + while (i < list.items.len) { + if (std.mem.eql(u8, list.items[i], value)) { + _ = list.swapRemove(i); + } else i += 1; + } +} + +fn promptSettingsPath(alloc: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/.codegraff-gui/prompt-settings.json", .{homeDir()}); +} + +fn guiStatePath(alloc: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/.codegraff-gui/state.json", .{homeDir()}); +} + +fn themeSettingsPath(alloc: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/.codegraff-gui/theme-settings.json", .{homeDir()}); +} + +fn legacyThemeSettingsPath(_: std.mem.Allocator) []const u8 { + return "/tmp/.codegraff-gui/theme-settings.json"; +} + +fn ensureSettingsDir(alloc: std.mem.Allocator) void { + ensureDirectory(alloc, std.fmt.allocPrint(alloc, "{s}/.codegraff-gui", .{homeDir()}) catch return) catch {}; +} + +fn ensureDirectory(_: std.mem.Allocator, path: []const u8) !void { + try std.Io.Dir.cwd().createDirPath(mer_runtime.io, path); +} + +fn validThemeMode(mode: []const u8) bool { + return std.mem.eql(u8, mode, "light") or std.mem.eql(u8, mode, "dark"); +} + +fn validThemePreset(preset: []const u8) bool { + return std.mem.eql(u8, preset, "warm-graphite") or + std.mem.eql(u8, preset, "slate") or + std.mem.eql(u8, preset, "nord") or + std.mem.eql(u8, preset, "forest"); +} + +fn readThemeSettings(alloc: std.mem.Allocator) ![]const u8 { + const path = try themeSettingsPath(alloc); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, alloc, .limited(16 * 1024)) catch { + const legacy_path = legacyThemeSettingsPath(alloc); + const legacy_data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, legacy_path, alloc, .limited(16 * 1024)) catch { + return "{\"mode\":null,\"preset\":null}"; + }; + ensureSettingsDir(alloc); + std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = legacy_data }) catch {}; + return normalizeThemeSettingsJson(alloc, legacy_data); + }; + return normalizeThemeSettingsJson(alloc, data); +} + +fn normalizeThemeSettingsJson(alloc: std.mem.Allocator, data: []const u8) ![]const u8 { + const v = std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch { + return "{\"mode\":null,\"preset\":null}"; + }; + if (v != .object) return "{\"mode\":null,\"preset\":null}"; + const mode = stringField(v, "mode"); + const preset = stringField(v, "preset"); + var out: std.Io.Writer.Allocating = .init(alloc); + try out.writer.writeAll("{\"mode\":"); + try writeNullableString(&out.writer, if (mode) |m| (if (validThemeMode(m)) m else null) else null); + try out.writer.writeAll(",\"preset\":"); + try writeNullableString(&out.writer, if (preset) |p| (if (validThemePreset(p)) p else null) else null); + try out.writer.writeAll("}"); + return out.written(); +} + +fn writeThemeSettings(alloc: std.mem.Allocator, mode: []const u8, preset: []const u8) !void { + ensureSettingsDir(alloc); + var out: std.Io.Writer.Allocating = .init(alloc); + try out.writer.writeAll("{\"mode\":"); + try writeString(&out.writer, mode); + try out.writer.writeAll(",\"preset\":"); + try writeString(&out.writer, preset); + try out.writer.writeAll("}"); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = try themeSettingsPath(alloc), .data = out.written() }); +} + +fn fileExists(path: []const u8) bool { + std.Io.Dir.cwd().access(mer_runtime.io, path, .{}) catch return false; + return true; +} + +fn generatedManagedChatsRoot(alloc: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/Library/Application Support/dev.codegraff.gui/managed-chats/", .{homeDir()}); +} + +fn isGeneratedManagedChatPath(alloc: std.mem.Allocator, path: []const u8) bool { + const root = generatedManagedChatsRoot(alloc) catch return false; + if (!std.mem.startsWith(u8, path, root)) return false; + const name = path[root.len..]; + if (!std.mem.startsWith(u8, name, "chat_")) return false; + const suffix = name["chat_".len..]; + if (suffix.len == 0) return false; + for (suffix) |c| { + if (c < '0' or c > '9') return false; + } + return true; +} + +fn sessionFilePath(alloc: std.mem.Allocator, workspace_path: []const u8, session_name: []const u8) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/{s}{s}", .{ workspace_path, session_name, session_ext }); +} + +fn mcpConfigPath(alloc: std.mem.Allocator, workspace_path: []const u8) ![]const u8 { + return std.fs.path.join(alloc, &.{ workspace_path, ".mcp.json" }); +} + +fn renderMcpSettingsJson(alloc: std.mem.Allocator, workspace: ?[]const u8) ![]const u8 { + const path = if (workspace) |w| try mcpConfigPath(alloc, w) else null; + var out: std.Io.Writer.Allocating = .init(alloc); + try out.writer.writeAll("{\"servers\":["); + if (path) |config_path| { + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, config_path, alloc, .limited(1024 * 1024)) catch null; + if (data) |json| { + var tmp = std.heap.ArenaAllocator.init(alloc); + defer tmp.deinit(); + const parsed = std.json.parseFromSliceLeaky(Value, tmp.allocator(), json, .{ .allocate = .alloc_always }) catch null; + if (parsed) |value| if (objectField(value, "mcpServers")) |servers| { + var first = true; + var it = servers.object.iterator(); + while (it.next()) |entry| { + if (entry.value_ptr.* != .object) continue; + if (!first) try out.writer.writeByte(','); + first = false; + try writeMcpServerSummary(&out.writer, entry.key_ptr.*, entry.value_ptr.*); + } + }; + } + } + try out.writer.writeAll("]}"); + return out.written(); +} + +fn writeMcpServerSummary(w: *std.Io.Writer, name: []const u8, server: Value) !void { + const command = stringField(server, "command") orelse stringField(server, "url") orelse ""; + try w.writeAll("{\"name\":"); + try writeString(w, name); + try w.writeAll(",\"serverType\":"); + try writeString(w, if (stringField(server, "url") != null) "http" else "stdio"); + try w.writeAll(",\"target\":"); + try writeString(w, command); + try w.writeAll(",\"isDisabled\":"); + try w.writeAll(if (boolField(server, "disabled") orelse false) "true" else "false"); + try w.writeAll(",\"toolCount\":0,\"tools\":[],\"error\":null,\"authStatus\":null}"); +} + +fn writeMcpConfig(alloc: std.mem.Allocator, workspace: []const u8, json_text: []const u8) !void { + const path = try mcpConfigPath(alloc, workspace); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = json_text }); +} + +fn removeMcpServerFromConfig(alloc: std.mem.Allocator, workspace: []const u8, name: []const u8) !void { + const path = try mcpConfigPath(alloc, workspace); + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, alloc, .limited(1024 * 1024)) catch return error.McpConfigNotFound; + var parsed = try std.json.parseFromSlice(Value, alloc, data, .{ .allocate = .alloc_always }); + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidMcpConfig; + const servers_ptr = parsed.value.object.getPtr("mcpServers") orelse return error.InvalidMcpConfig; + if (servers_ptr.* != .object) return error.InvalidMcpConfig; + if (!servers_ptr.object.swapRemove(name)) return error.McpServerNotFound; + var out: std.Io.Writer.Allocating = .init(alloc); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(parsed.value); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = out.written() }); +} + +fn messageKindFromString(value: []const u8) ?Message.Kind { + inline for (.{ "user", "context_compacted", "assistant", "reasoning", "status", "status_output", "tool_start", "tool_end", "error" }) |name| { + if (std.mem.eql(u8, value, name)) return std.meta.stringToEnum(Message.Kind, name).?; + } + return null; +} + +fn isGuiChatSessionName(value: []const u8) bool { + if (!std.mem.startsWith(u8, value, "chat-")) return false; + const suffix = value["chat-".len..]; + if (suffix.len != 16) return false; + for (suffix) |c| { + const hex = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F'); + if (!hex) return false; + } + return true; +} + +fn sessionMessageReasoning(message: Value) ?[]const u8 { + if (message != .object) return null; + if (message.object.get("reasoning_content")) |value| if (value == .string) return value.string; + if (message.object.get("reasoning")) |value| if (value == .string) return value.string; + return null; +} + +fn sessionMessageText(alloc: std.mem.Allocator, message: Value) []const u8 { + if (message != .object) return ""; + const content = message.object.get("content") orelse return ""; + if (content == .string) return content.string; + if (content != .array) return ""; + + var out: std.ArrayList(u8) = .empty; + for (content.array.items) |part| { + if (part != .object) continue; + const text = sessionContentText(part) orelse continue; + if (text.len == 0) continue; + if (out.items.len > 0) out.append(alloc, '\n') catch return out.items; + out.appendSlice(alloc, text) catch return out.items; + } + return out.items; +} + +fn sessionContentText(part: Value) ?[]const u8 { + if (part != .object) return null; + inline for (.{ "text", "input_text", "output_text" }) |field| { + if (part.object.get(field)) |value| if (value == .string) return value.string; + } + return null; +} + +const DeviceStart = struct { + provider: []const u8, + auth_session_id: []const u8, + verification_uri: []const u8, + verification_uri_complete: []const u8, + user_code: []const u8, + expires_in_seconds: ?i64, +}; + +const codegraff_device_base = "https://gateway.codegraff.com"; +const codegraff_key_file = ".simple-harness-codegraff.json"; +const kimi_oauth_host = "https://auth.kimi.com"; +const kimi_device_auth_url = kimi_oauth_host ++ "/api/oauth/device_authorization"; +const kimi_token_url = kimi_oauth_host ++ "/api/oauth/token"; +const kimi_client_id = "17e5f671-d194-4dfb-9706-5516cb48c098"; +const keychain_service = "simple-harness"; +const keys_file = ".simple-harness-keys.json"; + +fn deviceAuthResponse(req: mer.Request, provider: []const u8, started: DeviceStart) mer.Response { + _ = provider; + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"kind\":\"device_code\",\"authSessionId\":") catch return oom(); + writeString(&out.writer, started.auth_session_id) catch return oom(); + out.writer.writeAll(",\"requiresApiKey\":false,\"apiKeyHint\":null,\"urlParameters\":[],\"verificationUri\":") catch return oom(); + writeNullableString(&out.writer, started.verification_uri) catch return oom(); + out.writer.writeAll(",\"verificationUriComplete\":") catch return oom(); + writeNullableString(&out.writer, started.verification_uri_complete) catch return oom(); + out.writer.writeAll(",\"userCode\":") catch return oom(); + writeNullableString(&out.writer, started.user_code) catch return oom(); + out.writer.writeAll(",\"expiresInSeconds\":") catch return oom(); + if (started.expires_in_seconds) |expires| out.writer.print("{d}", .{expires}) catch return oom() else out.writer.writeAll("null") catch return oom(); + out.writer.writeAll(",\"authorizationUrl\":null}") catch return oom(); + return mer.json(out.written()); +} + +fn authErrorMessage(err: anyerror) []const u8 { + return switch (err) { + error.AuthorizationPending => "Authorization is still pending. Approve the login in your browser, then click Finish setup again.", + error.AuthorizationDenied => "Authorization was denied.", + error.AuthorizationExpired => "The authorization code expired. Start setup again.", + error.KeyStoreFailed => "Failed to store the provider key with graff key set.", + error.BadAuthResponse => "The provider returned an unexpected auth response.", + else => "Provider auth failed.", + }; +} + +fn startCodegraffDevice(alloc: std.mem.Allocator) !DeviceStart { + const resp = try httpPostJson(alloc, codegraff_device_base ++ "/v1/device/start", "{\"device_label\":\"codegraff-gui\"}", "application/json"); + const device_code = strFieldObj(resp, "device_code") orelse return error.BadAuthResponse; + const user_code = strFieldObj(resp, "user_code") orelse ""; + const verification_uri = strFieldObj(resp, "verification_uri") orelse "https://codegraff.com/cli/auth"; + const verification_uri_complete = strFieldObj(resp, "verification_uri_complete") orelse verification_uri; + return .{ + .provider = "codegraff", + .auth_session_id = try std.fmt.allocPrint(alloc, "codegraff-device:{s}", .{device_code}), + .verification_uri = verification_uri, + .verification_uri_complete = verification_uri_complete, + .user_code = user_code, + .expires_in_seconds = intFieldObj(resp, "expires_in", 600), + }; +} + +fn completeCodegraffDevice(alloc: std.mem.Allocator, device_code: []const u8) !void { + const poll_body = try std.fmt.allocPrint(alloc, "{{\"device_code\":\"{s}\"}}", .{device_code}); + const poll = try httpPostJson(alloc, codegraff_device_base ++ "/v1/device/poll", poll_body, "application/json"); + const status = strFieldObj(poll, "status") orelse "pending"; + if (std.mem.eql(u8, status, "ok")) { + const key = strFieldObj(poll, "api_key") orelse return error.BadAuthResponse; + return writeCodegraffKey(alloc, key); + } + if (std.mem.eql(u8, status, "denied")) return error.AuthorizationDenied; + if (std.mem.eql(u8, status, "expired")) return error.AuthorizationExpired; + return error.AuthorizationPending; +} + +fn startKimiDevice(alloc: std.mem.Allocator) !DeviceStart { + const da_body = try std.fmt.allocPrint(alloc, "client_id={s}", .{kimi_client_id}); + const resp = try httpPostJson(alloc, kimi_device_auth_url, da_body, "application/x-www-form-urlencoded"); + const device_code = strFieldObj(resp, "device_code") orelse return error.BadAuthResponse; + const user_code = strFieldObj(resp, "user_code") orelse ""; + const verify = strFieldObj(resp, "verification_uri_complete") orelse strFieldObj(resp, "verification_uri") orelse ""; + return .{ + .provider = "kimi", + .auth_session_id = try std.fmt.allocPrint(alloc, "kimi-device:{s}", .{device_code}), + .verification_uri = verify, + .verification_uri_complete = verify, + .user_code = user_code, + .expires_in_seconds = intFieldObj(resp, "expires_in", 900), + }; +} + +fn completeKimiDevice(alloc: std.mem.Allocator, device_code: []const u8) !void { + const body = try std.fmt.allocPrint(alloc, "client_id={s}&device_code={s}&grant_type=urn:ietf:params:oauth:grant-type:device_code", .{ kimi_client_id, device_code }); + const resp = try httpPostJson(alloc, kimi_token_url, body, "application/x-www-form-urlencoded"); + if (resp.get("access_token")) |a| if (a == .string and a.string.len > 0) { + const refresh = strFieldObj(resp, "refresh_token") orelse ""; + const expires_in = intFieldObj(resp, "expires_in", 900); + return writeKimiAuth(alloc, a.string, refresh, nowSeconds() + expires_in); + }; + const msg = strFieldObj(resp, "error") orelse "authorization_pending"; + if (std.mem.eql(u8, msg, "authorization_pending") or std.mem.eql(u8, msg, "slow_down")) return error.AuthorizationPending; + if (std.mem.eql(u8, msg, "expired_token")) return error.AuthorizationExpired; + return error.BadAuthResponse; +} + +fn httpPostJson(alloc: std.mem.Allocator, url: []const u8, body: []const u8, content_type: []const u8) !std.json.ObjectMap { + var client: std.http.Client = .{ .allocator = alloc, .io = mer_runtime.io }; + defer client.deinit(); + var out: std.Io.Writer.Allocating = .init(alloc); + _ = try client.fetch(.{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .response_writer = &out.writer, + .headers = .{ .content_type = .{ .override = content_type } }, + }); + const v = try std.json.parseFromSliceLeaky(Value, alloc, out.written(), .{ .allocate = .alloc_always }); + if (v != .object) return error.BadAuthResponse; + return v.object; +} + +fn strFieldObj(obj: std.json.ObjectMap, name: []const u8) ?[]const u8 { + const v = obj.get(name) orelse return null; + return if (v == .string) v.string else null; +} + +fn intFieldObj(obj: std.json.ObjectMap, name: []const u8, default: i64) i64 { + const v = obj.get(name) orelse return default; + return if (v == .integer) v.integer else default; +} + +fn homeDir() []const u8 { + if (mer_runtime.threaded.environString("HOME")) |home| { + if (home.len > 0 and !std.mem.eql(u8, home, "/tmp")) return home; + } + const alloc = std.heap.page_allocator; + if (std.c.getenv("HOME")) |home_z| { + const home = std.mem.span(home_z); + if (home.len > 0 and !std.mem.eql(u8, home, "/tmp")) return home; + } + if (builtin.os.tag == .macos) { + if (std.c.getenv("USER")) |user_z| { + const user = std.mem.span(user_z); + if (user.len > 0) return std.fmt.allocPrint(alloc, "/Users/{s}", .{user}) catch "/tmp"; + } + } + return "/tmp"; +} + +fn commandOutput(alloc: std.mem.Allocator, argv: []const []const u8) ![]const u8 { + var child = try std.process.spawn(mer_runtime.io, .{ + .argv = argv, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .ignore, + }); + const out_file = child.stdout orelse return error.CommandFailed; + var rbuf: [8 * 1024]u8 = undefined; + var reader = out_file.readerStreaming(mer_runtime.io, &rbuf); + const output = try reader.interface.allocRemaining(alloc, .limited(64 * 1024)); + const term = try child.wait(mer_runtime.io); + if (term != .exited or term.exited != 0) return error.CommandFailed; + return output; +} + +fn writeCodegraffKey(alloc: std.mem.Allocator, key: []const u8) !void { + const path = try std.fmt.allocPrint(alloc, "{s}/{s}", .{ homeDir(), codegraff_key_file }); + var obj: std.json.ObjectMap = .empty; + try obj.put(alloc, "api_key", .{ .string = key }); + var out: std.Io.Writer.Allocating = .init(alloc); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(Value{ .object = obj }); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = out.written() }); +} + +fn kimiAuthPath(alloc: std.mem.Allocator) ![]const u8 { + return std.fmt.allocPrint(alloc, "{s}/.kimi/credentials/graff-oauth.json", .{homeDir()}); +} + +fn writeKimiAuth(alloc: std.mem.Allocator, access: []const u8, refresh: []const u8, expires_at: i64) !void { + const home = homeDir(); + std.Io.Dir.cwd().createDir(mer_runtime.io, try std.fmt.allocPrint(alloc, "{s}/.kimi", .{home}), .default_dir) catch {}; + std.Io.Dir.cwd().createDir(mer_runtime.io, try std.fmt.allocPrint(alloc, "{s}/.kimi/credentials", .{home}), .default_dir) catch {}; + var obj: std.json.ObjectMap = .empty; + try obj.put(alloc, "access_token", .{ .string = access }); + try obj.put(alloc, "refresh_token", .{ .string = refresh }); + try obj.put(alloc, "expires_at", .{ .integer = expires_at }); + var out: std.Io.Writer.Allocating = .init(alloc); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(Value{ .object = obj }); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = try kimiAuthPath(alloc), .data = out.written() }); +} + +fn storeProviderKey(alloc: std.mem.Allocator, provider: []const u8, key: []const u8) !void { + const bin = codegraffBinaryPath(alloc); + var child = try std.process.spawn(mer_runtime.io, .{ + .argv = &.{ bin, "key", "set", provider, key }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }); + const term = try child.wait(mer_runtime.io); + if (term != .exited or term.exited != 0) return error.KeyStoreFailed; +} + +fn codegraffBinaryPath(alloc: std.mem.Allocator) []const u8 { + const home = homeDir(); + const p1 = std.fmt.allocPrint(alloc, "{s}/bin/graff", .{home}) catch "graff"; + if (fileExists(p1)) return p1; + const p2 = std.fmt.allocPrint(alloc, "{s}/.local/bin/graff", .{home}) catch "graff"; + if (fileExists(p2)) return p2; + inline for (.{ "/opt/homebrew/bin/graff", "/usr/local/bin/graff", "/usr/bin/graff" }) |p| { + if (fileExists(p)) return p; + } + return "graff"; +} + +fn providerConfigured(env_key: ?[]const u8) bool { + if (env_key) |key| { + const alloc = std.heap.page_allocator; + const zkey = alloc.dupeZ(u8, key) catch return false; + const value = std.c.getenv(zkey) orelse return false; + return std.mem.span(value).len > 0; + } + return false; +} + +fn writeEnvOverride(w: *std.Io.Writer, env_key: ?[]const u8) !void { + const key = providerCredentialEnvKey(env_key) orelse { + try w.writeAll("null"); + return; + }; + if (!providerConfigured(key)) { + try w.writeAll("null"); + return; + } + try w.writeAll("{\"envKey\":"); + try writeString(w, key); + try w.writeAll(",\"filePath\":null,\"line\":null}"); +} + +fn providerCredentialEnvKey(env_key: ?[]const u8) ?[]const u8 { + const key = env_key orelse return null; + // Core schema exposes CODEX_DISABLED as an implementation sentinel, not a + // credential source. Codex auth is read from ~/.codex/auth.json instead. + if (std.mem.eql(u8, key, "CODEX_DISABLED")) return null; + return key; +} + +fn providerConfiguredById(id: []const u8, env_key: ?[]const u8) bool { + if (providerConfigured(providerCredentialEnvKey(env_key))) return true; + if (builtin.is_test) return false; + const alloc = std.heap.page_allocator; + if (std.mem.eql(u8, id, "codegraff")) return codegraffStored(alloc); + if (std.mem.eql(u8, id, "codex")) return codexStored(alloc); + if (std.mem.eql(u8, id, "kimi")) return fileExists(std.fmt.allocPrint(alloc, "{s}/.kimi/credentials/graff-oauth.json", .{homeDir()}) catch return false) or storedKeyExists(alloc, id); + return storedKeyExists(alloc, id); +} + +fn codexStored(alloc: std.mem.Allocator) bool { + const path = std.fmt.allocPrint(alloc, "{s}/.codex/auth.json", .{homeDir()}) catch return false; + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, alloc, .limited(256 * 1024)) catch return false; + const v = std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch return false; + if (v != .object) return false; + const tokens = v.object.get("tokens") orelse return false; + if (tokens != .object) return false; + const access = tokens.object.get("access_token") orelse return false; + return access == .string and access.string.len > 0; +} + +fn codegraffStored(alloc: std.mem.Allocator) bool { + if (fileExists(std.fmt.allocPrint(alloc, "{s}/{s}", .{ homeDir(), codegraff_key_file }) catch return false)) return true; + const creds_path = std.fmt.allocPrint(alloc, "{s}/forge/.credentials.json", .{homeDir()}) catch return false; + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, creds_path, alloc, .limited(256 * 1024)) catch return false; + const v = std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch return false; + if (v != .array) return false; + for (v.array.items) |entry| { + if (entry != .object) continue; + const provider_id = if (entry.object.get("id")) |p| (if (p == .string) p.string else "") else ""; + if (!std.mem.eql(u8, provider_id, "codegraff")) continue; + if (entry.object.get("auth_details")) |auth| if (auth == .object) + if (auth.object.get("api_key")) |key| if (key == .string and key.string.len > 0) return true; + } + return false; +} + +fn storedKeyExists(alloc: std.mem.Allocator, provider: []const u8) bool { + if (builtin.os.tag == .macos) { + var child = std.process.spawn(mer_runtime.io, .{ + .argv = &.{ "security", "find-generic-password", "-s", keychain_service, "-a", provider, "-w" }, + .stdin = .ignore, + .stdout = .pipe, + .stderr = .ignore, + }) catch return false; + defer _ = child.wait(mer_runtime.io) catch {}; + const f = child.stdout orelse return false; + var rbuf: [8 * 1024]u8 = undefined; + var fr = f.readerStreaming(mer_runtime.io, &rbuf); + const out = fr.interface.allocRemaining(alloc, .limited(64 * 1024)) catch return false; + return std.mem.trim(u8, out, " \t\r\n").len > 0; + } + const path = std.fmt.allocPrint(alloc, "{s}/{s}", .{ homeDir(), keys_file }) catch return false; + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, alloc, .limited(64 * 1024)) catch return false; + const v = std.json.parseFromSliceLeaky(Value, alloc, data, .{ .allocate = .alloc_always }) catch return false; + if (v != .object) return false; + if (v.object.get(provider)) |key| return key == .string and key.string.len > 0; + return false; +} + +fn removeStoredProvider(alloc: std.mem.Allocator, provider: []const u8) !void { + if (std.mem.eql(u8, provider, "codegraff")) { + const path = try std.fmt.allocPrint(alloc, "{s}/{s}", .{ homeDir(), codegraff_key_file }); + std.Io.Dir.cwd().deleteFile(mer_runtime.io, path) catch {}; + return; + } + if (std.mem.eql(u8, provider, "codex")) { + const path = try std.fmt.allocPrint(alloc, "{s}/.codex/auth.json", .{homeDir()}); + std.Io.Dir.cwd().deleteFile(mer_runtime.io, path) catch {}; + return; + } + if (std.mem.eql(u8, provider, "kimi")) { + const path = try kimiAuthPath(alloc); + std.Io.Dir.cwd().deleteFile(mer_runtime.io, path) catch {}; + } + if (builtin.os.tag == .macos) { + var child = std.process.spawn(mer_runtime.io, .{ + .argv = &.{ "security", "delete-generic-password", "-s", keychain_service, "-a", provider }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }) catch return; + _ = child.wait(mer_runtime.io) catch {}; + return; + } + const path = std.fmt.allocPrint(alloc, "{s}/{s}", .{ homeDir(), keys_file }) catch return; + const data = std.Io.Dir.cwd().readFileAlloc(mer_runtime.io, path, alloc, .limited(64 * 1024)) catch return; + var parsed = std.json.parseFromSlice(Value, alloc, data, .{ .allocate = .alloc_always }) catch return; + defer parsed.deinit(); + if (parsed.value != .object) return; + _ = parsed.value.object.swapRemove(provider); + var out: std.Io.Writer.Allocating = .init(alloc); + var s: std.json.Stringify = .{ .writer = &out.writer }; + try s.write(parsed.value); + try std.Io.Dir.cwd().writeFile(mer_runtime.io, .{ .sub_path = path, .data = out.written() }); +} + +fn writeProviderSummariesFromSchema(w: *std.Io.Writer, schema: ?Value) bool { + const s = schema orelse return false; + const providers = arrayField(s, "providers") orelse return false; + w.writeByte('[') catch return false; + var first = true; + for (providers.items) |provider_value| { + if (provider_value != .object) continue; + const id = strFieldObj(provider_value.object, "id") orelse continue; + const name = strFieldObj(provider_value.object, "name") orelse id; + const login = strFieldObj(provider_value.object, "login") orelse providerInfo(id).auth_kind; + const env_key = strFieldObj(provider_value.object, "env_key"); + if (!first) w.writeByte(',') catch return false; + first = false; + writeProviderSummary(w, id, name, login, env_key) catch return false; + } + w.writeByte(']') catch return false; + return true; +} + +fn writeFallbackProviderSummaries(w: *std.Io.Writer) !void { + try w.writeByte('['); + for (fallback_providers, 0..) |p, idx| { + if (idx > 0) try w.writeByte(','); + try writeProviderSummary(w, p.id, p.name, p.auth_kind, p.env_key); + } + try w.writeByte(']'); +} + +fn writeProviderSummary(w: *std.Io.Writer, id: []const u8, name: []const u8, auth_kind: []const u8, env_key: ?[]const u8) !void { + try w.writeAll("{\"id\":"); + try writeString(w, id); + try w.writeAll(",\"name\":"); + try writeString(w, name); + try w.writeAll(",\"configured\":"); + try w.writeAll(if (providerConfiguredById(id, env_key)) "true" else "false"); + try w.writeAll(",\"authMethods\":[{\"kind\":"); + try writeString(w, auth_kind); + try w.writeAll(",\"label\":"); + try writeProviderAuthLabel(w, id, auth_kind, env_key); + try w.writeAll("}],\"envOverride\":"); + try writeEnvOverride(w, env_key); + try w.writeAll("}"); +} + +fn writeProviderAuthLabel(w: *std.Io.Writer, id: []const u8, auth_kind: []const u8, env_key: ?[]const u8) !void { + if (std.mem.eql(u8, auth_kind, "codegraff_device")) return writeString(w, "Codegraff device login"); + if (std.mem.eql(u8, auth_kind, "kimi_device")) return writeString(w, "Kimi device login"); + if (std.mem.eql(u8, auth_kind, "codex_device")) return writeString(w, "Shared Codex CLI login (~/.codex/auth.json)"); + if (std.mem.eql(u8, auth_kind, "api_key")) { + if (providerCredentialEnvKey(env_key)) |key| { + var label: std.Io.Writer.Allocating = .init(std.heap.page_allocator); + defer label.deinit(); + try label.writer.writeAll("API key ("); + try label.writer.writeAll(key); + try label.writer.writeAll(" or graff key set "); + try label.writer.writeAll(id); + try label.writer.writeByte(')'); + return writeString(w, label.written()); + } + return writeString(w, "API key (graff key set)"); + } + return writeString(w, auth_kind); +} + +fn providerSummaryResponse(req: mer.Request, provider: []const u8) mer.Response { + var out: std.Io.Writer.Allocating = .init(req.allocator); + const info = providerInfo(provider); + writeProviderSummary(&out.writer, provider, info.name, info.auth_kind, info.env_key) catch return oom(); + return mer.json(out.written()); +} + +const Provider = struct { id: []const u8, name: []const u8, env_key: ?[]const u8, auth_kind: []const u8, auth_label: []const u8 }; +const fallback_providers = [_]Provider{ + .{ .id = "codegraff", .name = "Codegraff", .env_key = "CODEGRAFF_API_KEY", .auth_kind = "codegraff_device", .auth_label = "Codegraff device login" }, + .{ .id = "anthropic", .name = "Anthropic", .env_key = "ANTHROPIC_API_KEY", .auth_kind = "api_key", .auth_label = "API key (ANTHROPIC_API_KEY or graff key set anthropic)" }, + .{ .id = "deepseek", .name = "DeepSeek", .env_key = "DEEPSEEK_API_KEY", .auth_kind = "api_key", .auth_label = "API key (DEEPSEEK_API_KEY or graff key set deepseek)" }, + .{ .id = "openai", .name = "OpenAI", .env_key = "OPENAI_API_KEY", .auth_kind = "api_key", .auth_label = "API key (OPENAI_API_KEY or graff key set openai)" }, + .{ .id = "minimax", .name = "MiniMax", .env_key = "MINIMAX_API_KEY", .auth_kind = "api_key", .auth_label = "API key (MINIMAX_API_KEY or graff key set minimax)" }, + .{ .id = "xiaomi", .name = "Xiaomi", .env_key = "XIAOMI_API_KEY", .auth_kind = "api_key", .auth_label = "API key (XIAOMI_API_KEY or graff key set xiaomi)" }, + .{ .id = "kimi", .name = "Kimi", .env_key = "KIMI_API_KEY", .auth_kind = "kimi_device", .auth_label = "Kimi device login" }, + .{ .id = "moonshot", .name = "Moonshot", .env_key = "MOONSHOT_API_KEY", .auth_kind = "api_key", .auth_label = "API key (MOONSHOT_API_KEY or graff key set moonshot)" }, + .{ .id = "xai", .name = "xAI", .env_key = "XAI_API_KEY", .auth_kind = "api_key", .auth_label = "API key (XAI_API_KEY or graff key set xai)" }, + .{ .id = "zai", .name = "Z.AI", .env_key = "ZAI_API_KEY", .auth_kind = "api_key", .auth_label = "API key (ZAI_API_KEY or graff key set zai)" }, + .{ .id = "codex", .name = "Codex / ChatGPT", .env_key = null, .auth_kind = "codex_device", .auth_label = "Shared Codex CLI login (~/.codex/auth.json)" }, +}; + +fn providerInfo(id: []const u8) Provider { + for (fallback_providers) |p| { + if (std.mem.eql(u8, p.id, id)) return p; + } + return .{ .id = id, .name = id, .env_key = null, .auth_kind = "api_key", .auth_label = "API key" }; +} + +fn bad(req: mer.Request, msg: []const u8) mer.Response { + var out: std.Io.Writer.Allocating = .init(req.allocator); + out.writer.writeAll("{\"error\":") catch return oom(); + writeString(&out.writer, msg) catch return oom(); + out.writer.writeAll("}") catch return oom(); + return mer.Response.init(.bad_request, .json, out.written()); +} + +fn badJson(req: mer.Request) mer.Response { + return bad(req, "body must be JSON"); +} + +fn oom() mer.Response { + return mer.Response.init(.internal_server_error, .json, "{\"error\":\"out of memory\"}"); +} + +const commands_json = + \\[{"name":"help","usage":"Show available commands.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":false,"requiresConversation":false,"argumentHint":null,"resultKind":"text"},{"name":"agent","usage":"Show active agent.","aliases":["agents"],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":false,"requiresConversation":false,"argumentHint":null,"resultKind":"agents"},{"name":"bash","usage":"Show a shell command for terminal execution.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"terminalAssisted","requiresWorkspace":true,"requiresConversation":false,"argumentHint":"","resultKind":"text"},{"name":"goal","usage":"Set/show the current objective.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":false,"requiresConversation":true,"argumentHint":"","resultKind":"text"},{"name":"loop","usage":"Run an autonomous plan-act-verify pass.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":true,"requiresConversation":true,"argumentHint":"","resultKind":"snapshot"},{"name":"compact","usage":"Compact current conversation.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":true,"requiresConversation":true,"argumentHint":null,"resultKind":"snapshot"},{"name":"workspace-status","usage":"Show git status.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":true,"requiresConversation":false,"argumentHint":null,"resultKind":"workspaceStatus"},{"name":"workspace-info","usage":"Show workspace metadata.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":true,"requiresConversation":false,"argumentHint":null,"resultKind":"workspaceInfo"},{"name":"workspace-query","usage":"Search workspace semantically.","aliases":["workspace-search"],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":true,"requiresConversation":false,"argumentHint":"","resultKind":"workspaceSearch"},{"name":"workflow","usage":"Draft a reviewable workflow.","aliases":[],"kind":"workflow","value":null,"isAgentSwitch":false,"executionKind":"modal","requiresWorkspace":false,"requiresConversation":false,"argumentHint":"","resultKind":"workflowDraft"},{"name":"reasoning-effort","usage":"Set reasoning effort.","aliases":["effort","reasoning"],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":false,"requiresConversation":false,"argumentHint":"","resultKind":"text"},{"name":"mcp","usage":"Show MCP server status.","aliases":[],"kind":"builtin","value":null,"isAgentSwitch":false,"executionKind":"runnable","requiresWorkspace":false,"requiresConversation":false,"argumentHint":null,"resultKind":"mcp"}] +; + +pub fn handleEvents( + ctx: *anyopaque, + alloc: std.mem.Allocator, + std_req: *std.http.Server.Request, + io: std.Io, +) bool { + const self: *Runtime = @ptrCast(@alignCast(ctx)); + const target = std_req.head.target; + const path = if (std.mem.indexOfScalar(u8, target, '?')) |q| target[0..q] else target; + if (!std.mem.eql(u8, path, "/events")) return false; + if (std_req.head.method != .GET) return false; + + var header_buf: [512]u8 = undefined; + var bw = std_req.respondStreaming(&header_buf, .{ + .respond_options = .{ + .status = .ok, + .extra_headers = &.{ + .{ .name = "content-type", .value = "text/event-stream" }, + .{ .name = "cache-control", .value = "no-cache" }, + .{ .name = "connection", .value = "keep-alive" }, + }, + }, + }) catch return true; + + if (!writeFrame(&bw, ": connected\n\n")) return true; + var seen = self.version.load(.acquire); + var force_snapshot = false; + var seen_event_seq: u64 = if (requestHeaderValue(std_req.head_buffer, "last-event-id")) |last_id| blk: { + const requested = std.fmt.parseInt(u64, std.mem.trim(u8, last_id, " \t\r\n"), 10) catch 0; + // Any reconnect may have missed version-only snapshot changes (tool_start, + // followup, errors), because only native events carry SSE ids. + force_snapshot = true; + self.event_mutex.lockUncancelable(io); + defer self.event_mutex.unlock(io); + break :blk self.clampRequestedEventSeqLocked(requested); + } else blk: { + self.event_mutex.lockUncancelable(io); + defer self.event_mutex.unlock(io); + break :blk if (self.next_event_seq > 0) self.next_event_seq - 1 else 0; + }; + while (true) { + _ = io.sleep(.fromMilliseconds(16), .awake) catch break; + + var pending: std.ArrayList(SseEvent) = .empty; + self.event_mutex.lockUncancelable(io); + for (self.events.items) |ev| { + if (ev.seq <= seen_event_seq) continue; + const cloned_name = alloc.dupe(u8, ev.name) catch break; + const cloned_data = alloc.dupe(u8, ev.data) catch { + alloc.free(cloned_name); + break; + }; + pending.append(alloc, .{ .seq = ev.seq, .name = cloned_name, .data = cloned_data }) catch { + alloc.free(cloned_name); + alloc.free(cloned_data); + break; + }; + } + self.event_mutex.unlock(io); + var write_ok = true; + for (pending.items) |ev| { + if (!writeSseEventFrame(&bw, ev)) { + write_ok = false; + break; + } + seen_event_seq = ev.seq; + } + for (pending.items) |ev| { + alloc.free(ev.name); + alloc.free(ev.data); + } + pending.deinit(alloc); + if (!write_ok) break; + + const current = self.version.load(.acquire); + if (current == seen and !force_snapshot) continue; + seen = current; + force_snapshot = false; + var snapshot_arena = std.heap.ArenaAllocator.init(alloc); + defer snapshot_arena.deinit(); + const json = self.snapshotJson(snapshot_arena.allocator()) catch break; + if (!writeSseNamedDataFrame(&bw, "session-updated", json)) break; + } + bw.end() catch {}; + return true; +} + +fn requestHeaderValue(head_buffer: []const u8, name: []const u8) ?[]const u8 { + var lines = std.mem.splitSequence(u8, head_buffer, "\r\n"); + _ = lines.next(); + while (lines.next()) |line| { + if (line.len == 0) break; + const sep = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const key = std.mem.trim(u8, line[0..sep], " \t"); + if (!std.ascii.eqlIgnoreCase(key, name)) continue; + return std.mem.trim(u8, line[sep + 1 ..], " \t"); + } + return null; +} + +fn writeSseEventFrame(bw: *std.http.BodyWriter, ev: SseEvent) bool { + bw.writer.print("id: {d}\nevent: {s}\ndata: {s}\n\n", .{ ev.seq, ev.name, ev.data }) catch return false; + bw.writer.flush() catch return false; + bw.http_protocol_output.flush() catch return false; + return true; +} + +fn writeSseNamedDataFrame(bw: *std.http.BodyWriter, name: []const u8, data: []const u8) bool { + bw.writer.print("event: {s}\ndata: {s}\n\n", .{ name, data }) catch return false; + bw.writer.flush() catch return false; + bw.http_protocol_output.flush() catch return false; + return true; +} + +fn writeFrame(bw: *std.http.BodyWriter, frame: []const u8) bool { + bw.writer.writeAll(frame) catch return false; + bw.writer.flush() catch return false; + bw.http_protocol_output.flush() catch return false; + return true; +} + +test "protocol ack events from core schema are consumed without unknown rows" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const conv = rt.createConversationLocked("/tmp/codegraff-gui-protocol-ack", "chat-protocol-ack", "Protocol ack"); + conv.session_name = ""; + try conv.active_request_ids.append(rt.arena, "request-1"); + + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const model_ack = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"type\":\"model\",\"ok\":true,\"provider\":\"codex\",\"model\":\"gpt-5\",\"context\":1000}", .{ .allocate = .alloc_always }); + try std.testing.expect(rt.handleProtocolAck(conv.conversation_id, "request-1", "model", model_ack)); + try std.testing.expectEqual(@as(usize, 0), conv.messages.items.len); + try std.testing.expectEqualStrings("codex", conv.session_provider.?); + try std.testing.expectEqualStrings("gpt-5", conv.session_model.?); + + const mode_ack = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"type\":\"mode\",\"ok\":true,\"mode\":\"plan\"}", .{ .allocate = .alloc_always }); + try std.testing.expect(rt.handleProtocolAck(conv.conversation_id, "request-1", "mode", mode_ack)); + try std.testing.expect(conv.plan_mode); + + inline for (.{ "system_prompt", "compact", "agent", "effort", "fast", "ultracode", "score" }) |ty| { + const event = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"type\":\"ack\",\"ok\":true}", .{ .allocate = .alloc_always }); + try std.testing.expect(rt.handleProtocolAck(conv.conversation_id, "request-1", ty, event)); + } + try std.testing.expectEqual(@as(usize, 0), conv.messages.items.len); + try std.testing.expect(!rt.handleProtocolAck(conv.conversation_id, "request-1", "future_event", model_ack)); +} + +test "appendProtocolWarning preserves malformed protocol rows" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const conv = rt.createConversationLocked("/tmp/codegraff-gui-protocol-warning", "chat-protocol", "Protocol warning"); + conv.session_name = ""; + try conv.active_request_ids.append(rt.arena, "request-1"); + + rt.appendProtocolWarning(conv.conversation_id, "request-1", "warning-1", "Malformed graff JSONL event", "not json"); + + try std.testing.expectEqual(@as(usize, 2), conv.messages.items.len); + try std.testing.expectEqual(.status, conv.messages.items[0].kind); + try std.testing.expectEqualStrings("Malformed graff JSONL event", conv.messages.items[0].title); + try std.testing.expectEqualStrings("debug", conv.messages.items[0].category); + try std.testing.expectEqual(.status_output, conv.messages.items[1].kind); + try std.testing.expectEqualStrings("not json", conv.messages.items[1].text); + + const invalid = [_]u8{0xff}; + rt.appendProtocolWarning(conv.conversation_id, "request-1", "warning-2", "Malformed graff JSONL event", invalid[0..]); + try std.testing.expectEqualStrings("�", conv.messages.items[3].text); + + const capped = rt.createConversationLocked("/tmp/codegraff-gui-protocol-warning-cap", "chat-protocol-cap", "Protocol warning cap"); + capped.session_name = ""; + try capped.active_request_ids.append(rt.arena, "request-2"); + var count: usize = 0; + for (0..protocol_warning_limit_per_turn + 3) |idx| { + count = rt.appendProtocolWarningLimited(capped.conversation_id, "request-2", count, rt.fmt("warning-{d}", .{idx}), "Malformed graff JSONL event", "bad line"); + } + try std.testing.expectEqual(@as(usize, (protocol_warning_limit_per_turn + 1) * 2), capped.messages.items.len); + try std.testing.expectEqualStrings("Additional graff protocol events suppressed", capped.messages.items[protocol_warning_limit_per_turn * 2].title); +} + +test "git mutation validation fails before shelling out" { + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const empty = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{}", .{ .allocate = .alloc_always }); + const blank_branch = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"branchName\":\" \"}", .{ .allocate = .alloc_always }); + const blank_message = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"message\":\" \"}", .{ .allocate = .alloc_always }); + const commit_message = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"message\":\"commit reviewed changes\"}", .{ .allocate = .alloc_always }); + const option_branch = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"branchName\":\"-f\"}", .{ .allocate = .alloc_always }); + const refspec_branch = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"branchName\":\":main\"}", .{ .allocate = .alloc_always }); + + try std.testing.expectError(error.MissingBranchName, runGitMutation(std.testing.allocator, "/tmp/workspace", "checkout_git_branch", empty)); + try std.testing.expectError(error.InvalidBranchName, runGitMutation(std.testing.allocator, "/tmp/workspace", "create_git_branch", blank_branch)); + try std.testing.expectError(error.MissingCommitMessage, runGitMutation(std.testing.allocator, "/tmp/workspace", "commit_git_changes", blank_message)); + try std.testing.expectError(error.CommitRequiresExplicitStaging, runGitMutation(std.testing.allocator, "/tmp/workspace", "commit_git_changes", commit_message)); + try std.testing.expectError(error.InvalidBranchName, runGitMutation(std.testing.allocator, "/tmp/workspace", "checkout_git_branch", option_branch)); + try std.testing.expectError(error.InvalidBranchName, runGitMutation(std.testing.allocator, "/tmp/workspace", "checkout_git_branch", refspec_branch)); + try std.testing.expect(isSafeGitBranchName("feature/safe-branch_1.2")); + try std.testing.expectError(error.UnsupportedGitMutation, runGitMutation(std.testing.allocator, "/tmp/workspace", "unknown_git_command", empty)); +} + +test "prompt models are ranked by persisted usage then name" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), + \\{"providers":[{"id":"codegraff","name":"Codegraff","env_key":"PATH"}],"models":[ + \\{"provider":"codegraff","name":"zeta"}, + \\{"provider":"codegraff","name":"alpha"}, + \\{"provider":"codegraff","name":"beta"} + \\]} + , .{ .allocate = .alloc_always }); + + rt.incrementModelUsageLocked("codegraff", "zeta", 10); + rt.incrementModelUsageLocked("codegraff", "zeta", 20); + rt.incrementModelUsageLocked("codegraff", "beta", 30); + + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try writePromptModels(std.testing.allocator, &out.writer, parsed, &rt.model_usage); + const json = out.written(); + const beta = std.mem.indexOf(u8, json, "\"modelId\":\"beta\"").?; + const zeta = std.mem.indexOf(u8, json, "\"modelId\":\"zeta\"").?; + const alpha = std.mem.indexOf(u8, json, "\"modelId\":\"alpha\"").?; + try std.testing.expect(zeta < beta); + try std.testing.expect(beta < alpha); +} + +test "provider summaries are generated from core schema" { + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), + \\{"providers":[ + \\{"id":"moonshot","name":"Moonshot","env_key":"MOONSHOT_API_KEY","login":"api_key"}, + \\{"id":"codex","name":"Codex (ChatGPT)","env_key":"CODEX_DISABLED","login":"codex_device"} + \\]} + , .{ .allocate = .alloc_always }); + + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try std.testing.expect(writeProviderSummariesFromSchema(&out.writer, parsed)); + + const json = out.written(); + try std.testing.expect(std.mem.indexOf(u8, json, "\"id\":\"moonshot\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "MOONSHOT_API_KEY or graff key set moonshot") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"id\":\"codex\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "Shared Codex CLI login") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"envKey\":\"CODEX_DISABLED\"") == null); +} + +test "activateWorkspaceLocked clears stale conversation and restores workspace selection" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const first_workspace = "/tmp/codegraff-gui-test-one"; + const second_workspace = "/tmp/codegraff-gui-test-two"; + const conv = rt.createConversationLocked(first_workspace, "chat-one", "First chat"); + rt.active_conversation_id = conv.conversation_id; + try rt.selected_by_workspace.put(conv.workspace_path, conv.conversation_id); + + rt.activateWorkspaceLocked(rt.dupe(second_workspace)); + try std.testing.expectEqualStrings(second_workspace, rt.active_workspace_path.?); + try std.testing.expect(rt.active_conversation_id == null); + + rt.activateWorkspaceLocked(conv.workspace_path); + try std.testing.expectEqualStrings(first_workspace, rt.active_workspace_path.?); + try std.testing.expectEqualStrings("chat-one", rt.active_conversation_id.?); +} + +test "passive session import does not change active workspace" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const active = rt.createConversationLocked("/tmp/codegraff-active-workspace", "chat-active", "Active"); + rt.active_conversation_id = active.conversation_id; + try rt.selected_by_workspace.put(active.workspace_path, active.conversation_id); + + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"provider\":\"codegraff\",\"model\":\"deepseek-v4-pro\",\"title\":\"Imported\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}", .{ .allocate = .alloc_always }); + rt.importSessionValueLocked("/tmp/codegraff-inactive-workspace", "imported", parsed, tmp.allocator()); + + try std.testing.expectEqualStrings("/tmp/codegraff-active-workspace", rt.active_workspace_path.?); + try std.testing.expectEqualStrings("chat-active", rt.active_conversation_id.?); +} + +test "session scan cache throttles and invalidates workspace scans" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const workspace = "/tmp/codegraff-gui-scan-cache"; + try std.testing.expect(rt.session_scan_cache.get(workspace) == null); + rt.markSessionScanLocked(workspace, 1000); + try std.testing.expectEqual(@as(i64, 1000), rt.session_scan_cache.get(workspace).?.scanned_ms); + rt.markSessionScanLocked(workspace, 2000); + try std.testing.expectEqual(@as(usize, 1), rt.session_scan_cache.count()); + try std.testing.expectEqual(@as(i64, 2000), rt.session_scan_cache.get(workspace).?.scanned_ms); + rt.invalidateSessionScanLocked(workspace); + try std.testing.expect(rt.session_scan_cache.get(workspace) == null); +} + +test "runtime status cache rejects stale stores after invalidation" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const generation = rt.runtime_status_generation; + rt.invalidateRuntimeStatusLocked("/tmp/workspace"); + const stale_key = try rt.allocator.dupe(u8, "/tmp/workspace"); + const stale_value = try rt.allocator.dupe(u8, "{\"branch\":\"old\"}"); + defer rt.allocator.free(stale_key); + defer rt.allocator.free(stale_value); + try std.testing.expect(!rt.storeRuntimeStatusCacheLocked("/tmp/workspace", stale_key, stale_value, 100, generation)); + try std.testing.expect(rt.runtime_status_cache.get("/tmp/workspace") == null); +} + +test "runtime status cache invalidates one workspace or all workspaces" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const key_a = try rt.allocator.dupe(u8, "/tmp/a"); + const key_b = try rt.allocator.dupe(u8, "/tmp/b"); + try rt.runtime_status_cache.put(key_a, .{ .json = try rt.allocator.dupe(u8, "{\"a\":true}"), .expires_ms = 10 }); + try rt.runtime_status_cache.put(key_b, .{ .json = try rt.allocator.dupe(u8, "{\"b\":true}"), .expires_ms = 10 }); + + rt.invalidateRuntimeStatusLocked("/tmp/a"); + try std.testing.expect(rt.runtime_status_cache.get("/tmp/a") == null); + try std.testing.expect(rt.runtime_status_cache.get("/tmp/b") != null); + rt.invalidateRuntimeStatusLocked(null); + try std.testing.expectEqual(@as(usize, 0), rt.runtime_status_cache.count()); +} + +test "emitSseEvent evicts and frees replay payloads" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const total = sse_event_replay_limit + 5; + for (0..total) |idx| { + rt.emitSseEvent("test-event", rt.fmt("payload-{d}", .{idx + 1})); + } + + try std.testing.expectEqual(@as(usize, sse_event_replay_limit), rt.events.items.len); + try std.testing.expectEqual(@as(u64, 6), rt.events.items[0].seq); + try std.testing.expectEqual(@as(u64, total), rt.events.items[rt.events.items.len - 1].seq); + try std.testing.expectEqualStrings("test-event", rt.events.items[0].name); + try std.testing.expectEqualStrings("payload-6", rt.events.items[0].data); + try std.testing.expectEqualStrings("payload-1005", rt.events.items[rt.events.items.len - 1].data); +} + +test "clampRequestedEventSeqLocked handles stale future Last-Event-ID" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + try std.testing.expectEqual(@as(u64, 0), rt.clampRequestedEventSeqLocked(500)); + rt.emitSseEvent("event", "one"); + try std.testing.expectEqual(@as(u64, 0), rt.clampRequestedEventSeqLocked(500)); + try std.testing.expectEqual(@as(u64, 0), rt.clampRequestedEventSeqLocked(0)); + + for (0..sse_event_replay_limit + 5) |idx| { + rt.emitSseEvent("event", rt.fmt("more-{d}", .{idx})); + } + try std.testing.expectEqual(@as(u64, 7), rt.events.items[0].seq); + try std.testing.expectEqual(@as(u64, 6), rt.clampRequestedEventSeqLocked(0)); + try std.testing.expectEqual(@as(u64, 6), rt.clampRequestedEventSeqLocked(999_999)); +} + +test "importSessionValueLocked imports CLI session files" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const data = + \\{"provider":"codegraff","model":"deepseek-v4-pro","strict":false,"ultracode_mode":false,"goal":"ship persistence","title":"CLI title","updated_ms":42,"messages":[ + \\{"role":"user","content":"hello from cli"}, + \\{"role":"assistant","reasoning_content":"thinking through it","content":[{"type":"text","text":"hello from gui"}]}, + \\{"role":"tool","content":"hidden"} + \\]} + ; + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }); + + const workspace = "/tmp/codegraff-gui-session-import"; + rt.importSessionValueLocked(workspace, "last", parsed, tmp.allocator()); + rt.ensureWorkspaceSelectionLocked(workspace); + + const cid = rt.conversationIdForSession(workspace, "last"); + const conv = rt.conversations.get(cid) orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings("last", conv.session_name); + try std.testing.expectEqualStrings("CLI title", conv.title); + try std.testing.expectEqualStrings("ship persistence", conv.goal.?); + try std.testing.expectEqual(@as(usize, 3), conv.messages.items.len); + try std.testing.expectEqual(.user, conv.messages.items[0].kind); + try std.testing.expectEqualStrings("hello from cli", conv.messages.items[0].text); + try std.testing.expectEqual(.reasoning, conv.messages.items[1].kind); + try std.testing.expectEqualStrings("thinking through it", conv.messages.items[1].text); + try std.testing.expectEqual(.assistant, conv.messages.items[2].kind); + try std.testing.expectEqualStrings("hello from gui", conv.messages.items[2].text); + try std.testing.expectEqualStrings(cid, rt.selected_by_workspace.get(workspace).?); +} + +test "importSessionValueLocked preserves GUI chat ids from session filenames" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const session_name = "chat-0123456789abcdef"; + const workspace = "/tmp/codegraff-gui-session-id"; + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"provider\":\"codegraff\",\"model\":\"deepseek-v4-pro\",\"messages\":[],\"updated_ms\":1}", .{ .allocate = .alloc_always }); + + rt.importSessionValueLocked(workspace, session_name, parsed, tmp.allocator()); + const conv = rt.conversations.get(session_name) orelse return error.TestExpectedEqual; + try std.testing.expectEqualStrings(session_name, conv.conversation_id); + try std.testing.expectEqualStrings(session_name, conv.session_name); +} + +test "conversationSessionJsonLocked writes CLI-readable session JSON" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const workspace = "/tmp/codegraff-gui-session-write"; + const session_name = "chat-fedcba9876543210"; + + const conv = rt.createConversationLocked(workspace, session_name, "New chat"); + conv.updated_at = 123; + conv.messages.append(rt.arena, .{ .kind = .user, .id = "u", .request_id = "r", .text = "persist me" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .assistant, .id = "a", .request_id = "r", .text = "persisted" }) catch {}; + const data = try rt.conversationSessionJsonLocked(conv, "codegraff", "deepseek-v4-pro"); + defer rt.allocator.free(data); + + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }); + try std.testing.expectEqualStrings("codegraff", stringField(parsed, "provider").?); + try std.testing.expectEqualStrings("deepseek-v4-pro", stringField(parsed, "model").?); + try std.testing.expectEqualStrings("New chat", stringField(parsed, "title").?); + const messages = arrayField(parsed, "messages").?; + try std.testing.expectEqual(@as(usize, 2), messages.items.len); + try std.testing.expectEqualStrings("user", stringField(messages.items[0], "role").?); + try std.testing.expectEqualStrings("persist me", stringField(messages.items[0], "content").?); + try std.testing.expectEqualStrings("assistant", stringField(messages.items[1], "role").?); + try std.testing.expectEqualStrings("persisted", stringField(messages.items[1], "content").?); +} + +test "turnPromptSelectionLocked reconciles selected model over imported CLI sessions" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + rt.settings.selected_provider = "codegraff"; + rt.settings.selected_model = "deepseek-v4-pro"; + const conv = rt.createConversationLocked("/tmp/codegraff-gui-turn-selection", "chat-3333333333333333", "Imported"); + conv.session_provider = "codex"; + conv.session_model = "gpt-5"; + + const selection = rt.turnPromptSelectionLocked(conv.conversation_id); + try std.testing.expectEqualStrings("codegraff", selection.provider.?); + try std.testing.expectEqualStrings("deepseek-v4-pro", selection.model.?); + try std.testing.expect(selection.send_model_control); +} + +test "conversationSessionJsonLocked preserves imported CLI-native messages" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const data = + \\{"provider":"codex","model":"gpt-5","strict":true,"ultracode_mode":true,"title":"Native","messages":[ + \\{"role":"user","content":"inspect"}, + \\{"role":"assistant","content":[{"type":"text","text":"I will read"},{"type":"tool_use","id":"call-1","name":"read_file","input":{"path":"src/main.zig"}}]}, + \\{"role":"tool","tool_call_id":"call-1","content":"file contents"} + \\]} + ; + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }); + + const workspace = "/tmp/codegraff-gui-native-preserve"; + rt.importSessionValueLocked(workspace, "native", parsed, tmp.allocator()); + const cid = rt.conversationIdForSession(workspace, "native"); + const conv = rt.conversations.get(cid) orelse return error.TestExpectedEqual; + conv.messages.append(rt.arena, .{ .kind = .tool_start, .id = "gui-tool", .request_id = "r", .name = "read_file", .call_id = "call-1", .tool_detail_json = "{\"kind\":\"file_read\",\"path\":\"src/main.zig\",\"startLine\":null,\"endLine\":null}" }) catch {}; + + const written = try rt.conversationSessionJsonLocked(conv, "codex", "gpt-5"); + defer rt.allocator.free(written); + var check_tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer check_tmp.deinit(); + const saved = try std.json.parseFromSliceLeaky(Value, check_tmp.allocator(), written, .{ .allocate = .alloc_always }); + try std.testing.expectEqualStrings("codex", stringField(saved, "provider").?); + try std.testing.expectEqualStrings("gpt-5", stringField(saved, "model").?); + try std.testing.expect(boolField(saved, "strict").?); + try std.testing.expect(boolField(saved, "ultracode_mode").?); + const messages = arrayField(saved, "messages").?; + try std.testing.expectEqual(@as(usize, 3), messages.items.len); + try std.testing.expectEqualStrings("tool", stringField(messages.items[2], "role").?); + try std.testing.expectEqualStrings("call-1", stringField(messages.items[2], "tool_call_id").?); + const gui_messages = arrayField(saved, "guiMessages").?; + try std.testing.expect(gui_messages.items.len >= 1); +} + +test "conversationSessionJsonLocked round-trips rich GUI messages" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const workspace = "/tmp/codegraff-gui-rich-session"; + const conv = rt.createConversationLocked(workspace, "chat-1111111111111111", "Rich chat"); + conv.updated_at = 456; + conv.goal = "preserve display state"; + conv.messages.append(rt.arena, .{ .kind = .user, .id = "u", .request_id = "r", .text = "inspect" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .reasoning, .id = "reason", .request_id = "r", .text = "thinking" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .status, .id = "st", .request_id = "r", .title = "Unhandled graff event: protocol_extension", .subtitle = "Preserved raw event for protocol compatibility.", .category = "debug" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .status_output, .id = "sto", .request_id = "r", .text = "{\"type\":\"protocol_extension\",\"value\":1}" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .tool_start, .id = "ts", .request_id = "r", .name = "read_file", .call_id = "call-1", .tool_detail_json = "{\"kind\":\"file_read\",\"path\":\"src/main.zig\",\"startLine\":null,\"endLine\":null}" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .tool_end, .id = "te", .request_id = "r", .name = "read_file", .call_id = "call-1", .summary = "read file", .is_error = false, .result_detail_json = "{\"kind\":\"text\",\"text\":\"contents\"}" }) catch {}; + conv.messages.append(rt.arena, .{ .kind = .@"error", .id = "err", .request_id = "r", .error_message = "boom" }) catch {}; + + const data = try rt.conversationSessionJsonLocked(conv, "codegraff", "deepseek-v4-pro"); + defer rt.allocator.free(data); + + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const parsed = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), data, .{ .allocate = .alloc_always }); + const cli_messages = arrayField(parsed, "messages").?; + try std.testing.expectEqual(@as(usize, 1), cli_messages.items.len); + const gui_messages = arrayField(parsed, "guiMessages").?; + try std.testing.expectEqual(@as(usize, 7), gui_messages.items.len); + try std.testing.expectEqualStrings("reasoning", stringField(gui_messages.items[1], "kind").?); + try std.testing.expectEqualStrings("status", stringField(gui_messages.items[2], "kind").?); + try std.testing.expectEqualStrings("debug", stringField(gui_messages.items[2], "category").?); + try std.testing.expectEqualStrings("status_output", stringField(gui_messages.items[3], "kind").?); + try std.testing.expectEqualStrings("tool_start", stringField(gui_messages.items[4], "kind").?); + try std.testing.expectEqualStrings("call-1", stringField(gui_messages.items[4], "callId").?); + try std.testing.expectEqualStrings("tool_end", stringField(gui_messages.items[5], "kind").?); + try std.testing.expectEqualStrings("error", stringField(gui_messages.items[6], "kind").?); + + rt.importSessionValueLocked(workspace, "chat-2222222222222222", parsed, tmp.allocator()); + const restored = rt.conversations.get("chat-2222222222222222") orelse return error.TestExpectedEqual; + try std.testing.expectEqual(@as(usize, 7), restored.messages.items.len); + try std.testing.expectEqual(.reasoning, restored.messages.items[1].kind); + try std.testing.expectEqualStrings("thinking", restored.messages.items[1].text); + try std.testing.expectEqual(.status, restored.messages.items[2].kind); + try std.testing.expectEqualStrings("Unhandled graff event: protocol_extension", restored.messages.items[2].title); + try std.testing.expectEqualStrings("debug", restored.messages.items[2].category); + try std.testing.expectEqual(.status_output, restored.messages.items[3].kind); + try std.testing.expectEqualStrings("{\"type\":\"protocol_extension\",\"value\":1}", restored.messages.items[3].text); + try std.testing.expectEqual(.tool_start, restored.messages.items[4].kind); + try std.testing.expectEqualStrings("read_file", restored.messages.items[4].name); + try std.testing.expectEqualStrings("call-1", restored.messages.items[4].call_id.?); + try std.testing.expect(restored.messages.items[4].tool_detail_json != null); + try std.testing.expectEqual(.tool_end, restored.messages.items[5].kind); + try std.testing.expectEqualStrings("read file", restored.messages.items[5].summary.?); + try std.testing.expect(restored.messages.items[5].result_detail_json != null); + try std.testing.expectEqual(.@"error", restored.messages.items[6].kind); + try std.testing.expectEqualStrings("boom", restored.messages.items[6].error_message); +} + +test "writeFollowup serializes options and kind" { + var rt = try Runtime.init(std.testing.allocator); + defer rt.deinit(); + + const followup = Followup{ + .followup_id = "call-1", + .workspace_path = "/tmp/workspace", + .conversation_id = "chat-1", + .request_id = "request-1", + .kind = "single", + .question = "Choose one", + .options = &.{ + .{ .id = "opt-1", .label = "First" }, + .{ .id = "opt-2", .label = "Second" }, + }, + .call_id = "call-1", + }; + var out: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer out.deinit(); + try rt.writeFollowup(&out.writer, followup); + + try std.testing.expectEqualStrings( + "{\"followupId\":\"call-1\",\"workspacePath\":\"/tmp/workspace\",\"conversationId\":\"chat-1\",\"requestId\":\"request-1\",\"kind\":\"single\",\"question\":\"Choose one\",\"options\":[{\"id\":\"opt-1\",\"label\":\"First\"},{\"id\":\"opt-2\",\"label\":\"Second\"}]}", + out.written(), + ); +} + +test "answerLineJson combines selected option labels and notes" { + var tmp = std.heap.ArenaAllocator.init(std.testing.allocator); + defer tmp.deinit(); + const selected = try std.json.parseFromSliceLeaky(Value, tmp.allocator(), "{\"selectedOptionIds\":[\"opt-2\"]}", .{ .allocate = .alloc_always }); + const followup = Followup{ + .followup_id = "call-1", + .workspace_path = "/tmp/workspace", + .conversation_id = "chat-1", + .request_id = "request-1", + .kind = "single", + .question = "Choose one", + .options = &.{ + .{ .id = "opt-1", .label = "First" }, + .{ .id = "opt-2", .label = "Second" }, + }, + .call_id = "call-1", + }; + const line = try answerLineJson(std.testing.allocator, followup, "because it is safer", arrayField(selected, "selectedOptionIds"), false); + defer std.testing.allocator.free(line); + + try std.testing.expectEqualStrings( + "{\"type\":\"answer\",\"text\":\"Second\\n\\nNotes: because it is safer\",\"cancelled\":false,\"call_id\":\"call-1\"}", + line, + ); +} diff --git a/gui/src-tauri/.gitignore b/gui/src-tauri/.gitignore deleted file mode 100644 index f4557218..00000000 --- a/gui/src-tauri/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Generated by Cargo -# will have compiled files and executables -/target/ -/gen/schemas - -# Staged CLI binary bundled into the app at build time (local artifact) -binaries/ diff --git a/gui/src-tauri/Cargo.lock b/gui/src-tauri/Cargo.lock deleted file mode 100644 index b4669024..00000000 --- a/gui/src-tauri/Cargo.lock +++ /dev/null @@ -1,7111 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "android_log-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" - -[[package]] -name = "android_logger" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" -dependencies = [ - "android_log-sys", - "env_filter", - "log", -] - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" - -[[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] -name = "async-broadcast" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" -dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix", -] - -[[package]] -name = "async-recursion" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borsh" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases 0.2.1", -] - -[[package]] -name = "borsh-derive" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" -dependencies = [ - "once_cell", - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "bumpalo" -version = "3.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" - -[[package]] -name = "byte-unit" -version = "5.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" -dependencies = [ - "rust_decimal", - "schemars 1.2.1", - "serde", - "utf8-width", -] - -[[package]] -name = "bytecheck" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" -dependencies = [ - "bytecheck_derive", - "ptr_meta", - "simdutf8", -] - -[[package]] -name = "bytecheck_derive" -version = "0.6.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "byteorder-lite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.11.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "camino" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "cc" -version = "1.2.60" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfb" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link 0.2.1", -] - -[[package]] -name = "chunked_transfer" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" - -[[package]] -name = "codegraff-gui" -version = "0.0.166" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.22.1", - "bstr", - "bytes", - "diesel", - "futures", - "image", - "log", - "portable-pty", - "pretty_assertions", - "reqwest 0.12.28", - "roxmltree", - "rustls", - "serde", - "serde_json", - "similar", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-log", - "tauri-plugin-process", - "tauri-plugin-single-instance", - "tauri-plugin-updater", - "tiny_http", - "tokio", - "tonic", - "ts-rs", - "url", - "urlencoding", - "uuid", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.11.1", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.11.1", - "core-foundation", - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf 0.13.1", - "smallvec", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "ctor" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", - "serde_core", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.117", -] - -[[package]] -name = "diesel" -version = "2.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78df0e4e8c596662edb07fbfbb7f23769cca35049827df5f909084d956b6aeaf" -dependencies = [ - "diesel_derives", - "downcast-rs 2.0.2", - "libsqlite3-sys", - "sqlite-wasm-rs", - "time", -] - -[[package]] -name = "diesel_derives" -version = "2.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" -dependencies = [ - "diesel_table_macro_syntax", - "dsl_auto_type", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "diesel_table_macro_syntax" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" -dependencies = [ - "syn 2.0.117", -] - -[[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.11.1", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dom_query" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set", - "cssparser 0.36.0", - "foldhash 0.2.0", - "html5ever 0.38.0", - "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.0", -] - -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[package]] -name = "dsl_auto_type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" -dependencies = [ - "darling 0.21.3", - "either", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "embed-resource" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 0.9.12+spec-1.1.0", - "vswhom", - "winreg 0.55.0", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[package]] -name = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "fern" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" -dependencies = [ - "log", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.11.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.4", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.4", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - -[[package]] -name = "html5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" -dependencies = [ - "log", - "markup5ever 0.38.0", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "image" -version = "0.25.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" -dependencies = [ - "bytemuck", - "byteorder-lite", - "color_quant", - "gif", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "zune-core", - "zune-jpeg", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry", - "windows-result 0.4.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "js-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.11.1", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser 0.29.6", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors 0.24.0", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "libc", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "value-bag", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache 0.8.9", - "string_cache_codegen 0.5.4", - "tendril 0.4.3", -] - -[[package]] -name = "markup5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" -dependencies = [ - "log", - "tendril 0.5.0", - "web_atoms", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minisign-verify" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.61.2", -] - -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "moxcms" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" -dependencies = [ - "num-traits", - "pxfm", -] - -[[package]] -name = "muda" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.11.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.11.1", - "cfg-if", - "cfg_aliases 0.1.1", - "libc", -] - -[[package]] -name = "nodrop" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.11.1", - "block2", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.11.1", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.11.1", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.11.1", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-app-kit", - "objc2-foundation", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.11.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.11.1", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.5", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher 1.0.2", -] - -[[package]] -name = "pin-project" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plist" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" -dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.11.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs 1.2.1", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "ptr_meta" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" -dependencies = [ - "ptr_meta_derive", -] - -[[package]] -name = "ptr_meta_derive" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "pxfm" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.38.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases 0.2.1", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases 0.2.1", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] - -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", -] - -[[package]] -name = "ref-cast" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "h2", - "hickory-resolver", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "rsqlite-vfs" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.18", -] - -[[package]] -name = "rust_decimal" -version = "1.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.5", - "rkyv", - "serde", - "serde_json", - "wasm-bindgen", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.117", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.11.1", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser 0.29.6", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc 0.2.0", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.11.1", - "cssparser 0.36.0", - "derive_more 2.1.1", - "log", - "new_debug_unreachable", - "phf 0.13.1", - "phf_codegen 0.13.1", - "precomputed-hash", - "rustc-hash", - "servo_arc 0.4.3", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serial2" -version = "0.2.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdbc46aa3882ec3d48ec2b5abcb4f0d863a13d7599265f3faa6d851f23c12f3" -dependencies = [ - "cfg-if", - "libc", - "winapi", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "similar" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d93e861ede2e497b47833469b8ec9d5c07fa4c78ce7a00f6eb7dd8168b4b3f" -dependencies = [ - "bstr", -] - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall", - "tracing", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.13.1", - "precomputed-hash", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "swift-rs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tao" -version = "0.34.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" -dependencies = [ - "bitflags 2.11.1", - "block2", - "core-foundation", - "core-graphics", - "crossbeam-channel", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "parking_lot", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tauri" -version = "2.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "jni", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest 0.13.2", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.18", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows", -] - -[[package]] -name = "tauri-build" -version = "2.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2", - "syn 2.0.117", - "tauri-utils", - "thiserror 2.0.18", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "toml 0.9.12+spec-1.1.0", - "walkdir", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1fa4150c95ae391946cc8b8f905ab14797427caba3a8a2f79628e956da91809" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.18", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36e1ec28b79f3d0683f4507e1615c36292c0ea6716668770d4396b9b39871ed8" -dependencies = [ - "anyhow", - "dunce", - "glob", - "log", - "objc2-foundation", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "url", -] - -[[package]] -name = "tauri-plugin-log" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7545bd67f070a4500432c826e2e0682146a1d6712aee22a2786490156b574d93" -dependencies = [ - "android_logger", - "byte-unit", - "fern", - "log", - "objc2", - "objc2-foundation", - "serde", - "serde_json", - "serde_repr", - "swift-rs", - "tauri", - "tauri-plugin", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "tauri-plugin-process" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" -dependencies = [ - "tauri", - "tauri-plugin", -] - -[[package]] -name = "tauri-plugin-single-instance" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" -dependencies = [ - "serde", - "serde_json", - "tauri", - "thiserror 2.0.18", - "tracing", - "windows-sys 0.60.2", - "zbus", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" -dependencies = [ - "base64 0.22.1", - "dirs", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest 0.13.2", - "rustls", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.18", - "time", - "tokio", - "url", - "windows-sys 0.60.2", - "zip", -] - -[[package]] -name = "tauri-runtime" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webview2-com", - "windows", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" -dependencies = [ - "gtk", - "http", - "jni", - "log", - "objc2", - "objc2-app-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dunce", - "glob", - "html5ever 0.29.1", - "http", - "infer", - "json-patch", - "kuchikiki", - "log", - "memchr", - "phf 0.11.3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.18", - "toml 0.9.12+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" -dependencies = [ - "dunce", - "embed-resource", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.2", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "tendril" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" -dependencies = [ - "new_debug_unreachable", - "utf-8", -] - -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny_http" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" -dependencies = [ - "ascii", - "chunked_transfer", - "httpdate", - "log", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tonic" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" -dependencies = [ - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", - "webpki-roots", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap 2.14.0", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags 2.11.1", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "tray-icon" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.17.16", - "serde", - "thiserror 2.0.18", - "windows-sys 0.60.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ts-rs" -version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" -dependencies = [ - "thiserror 2.0.18", - "ts-rs-macros", -] - -[[package]] -name = "ts-rs-macros" -version = "12.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "termcolor", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.68" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.117", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web_atoms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" -dependencies = [ - "phf 0.13.1", - "phf_codegen 0.13.1", - "string_cache 0.9.0", - "string_cache_codegen 0.6.1", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.18", - "windows", - "windows-core 0.61.2", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "wry" -version = "0.54.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2", - "soup3", - "tao-macros", - "thiserror 2.0.18", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - -[[package]] -name = "yoke" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zbus" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.1", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" -dependencies = [ - "serde", - "winnow 1.0.1", - "zvariant", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerofrom" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zip" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" -dependencies = [ - "arbitrary", - "crc32fast", - "indexmap 2.14.0", - "memchr", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core", -] - -[[package]] -name = "zvariant" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.1", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "winnow 1.0.1", -] diff --git a/gui/src-tauri/Cargo.toml b/gui/src-tauri/Cargo.toml deleted file mode 100644 index 92562679..00000000 --- a/gui/src-tauri/Cargo.toml +++ /dev/null @@ -1,57 +0,0 @@ -# This crate is its own workspace root: it lives inside the codegraff repo but -# is decoupled from the root cargo workspace. The empty [workspace] table stops -# cargo from walking up into codegraff's root workspace while keeping GUI build -# artifacts and lockfiles local to gui/src-tauri. -[workspace] - -[package] -name = "codegraff-gui" -version = "0.0.166" -description = "Desktop coding agent interface powered by Codegraff" -authors = ["Codegraff contributors"] -license = "Apache-2.0" -repository = "https://github.com/justrach/codegraff" -edition = "2024" -rust-version = "1.92" -default-run = "codegraff-gui" - -[lib] -name = "codegraff_gui_lib" -crate-type = ["staticlib", "cdylib", "rlib"] - -[build-dependencies] -tauri-build = { version = "2.5.6", features = [] } - -[dependencies] -anyhow = "1.0" -async-trait = "0.1" -bstr = "1.12" -bytes = "1.10" -diesel = { version = "2.3.7", features = ["sqlite"] } -futures = "0.3" -log = "0.4" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "hickory-dns", "http2"] } -rustls = { version = "0.23", default-features = false, features = ["ring"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -roxmltree = "0.20" -similar = "3.1.0" -tauri = { version = "2.10.3", features = ["macos-private-api"] } -tauri-plugin-dialog = "2" -tauri-plugin-single-instance = "2" -tauri-plugin-log = "2" -tauri-plugin-updater = "2" -tauri-plugin-process = "2" -tokio = { version = "1.51.0", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] } -tonic = { version = "0.14.5", features = ["tls-webpki-roots"] } -ts-rs = "12.0.1" -url = { version = "2.5.8", features = ["serde"] } -urlencoding = "2.1.3" -uuid = { version = "1.23.0", features = ["v4", "serde"] } -portable-pty = "0.9.0" -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } -base64 = "0.22" -tiny_http = "0.12" - -[dev-dependencies] -pretty_assertions = "1.4.1" diff --git a/gui/src-tauri/build.rs b/gui/src-tauri/build.rs deleted file mode 100644 index d860e1e6..00000000 --- a/gui/src-tauri/build.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - tauri_build::build() -} diff --git a/gui/src-tauri/capabilities/default.json b/gui/src-tauri/capabilities/default.json deleted file mode 100644 index 586c463b..00000000 --- a/gui/src-tauri/capabilities/default.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "../gen/schemas/desktop-schema.json", - "identifier": "default", - "description": "enables the default permissions", - "windows": [ - "main" - ], - "permissions": [ - "core:default", - "core:window:allow-start-dragging", - "dialog:default", - "updater:default", - "process:default" - ] -} diff --git a/gui/src-tauri/examples/generate_desktop_contracts.rs b/gui/src-tauri/examples/generate_desktop_contracts.rs deleted file mode 100644 index d6a7f723..00000000 --- a/gui/src-tauri/examples/generate_desktop_contracts.rs +++ /dev/null @@ -1,152 +0,0 @@ -use std::fs; -use std::path::PathBuf; - -use anyhow::Context; -use codegraff_gui_lib::dto::{ - AgentSummaryDto, AgentsPayloadDto, ChatBindingDto, ChatHandoffTargetDto, - CheckoutGitBranchInput, CloneRepositoryInput, CommandDescriptorDto, CommandExecutionKindDto, - CommandKindDto, CommandPayloadDto, CommandResultKindDto, CommandRunResultDto, - CommitGitChangesInput, CompleteProviderAuthInput, ConversationSessionSummaryDto, - ConversationViewSnapshotDto, ConversationsPayloadDto, CreateGitBranchInput, - CreateSavedWorkspaceInput, FileOperationDto, FollowupKind, FollowupOptionDto, - FollowupRequestDto, FollowupResponseDto, GitWorkspaceKindDto, HandoffChatInput, McpImportInput, - McpServerActionInput, McpServerSummaryDto, McpSettingsPayloadDto, OutputPreviewDto, - PromptModelOptionDto, PromptSettingsDto, ProviderAuthMethodDto, ProviderAuthMethodKindDto, - ProviderEnvOverrideDto, - ProviderAuthSessionDto, ProviderAuthSessionKindDto, ProviderOAuthCallbackDto, - ProviderSummaryDto, ProviderUrlParamDto, ProviderUrlParamValueDto, QuickStartProjectInput, - QuickStartVisibility, RemoveProviderInput, RuntimeStatusDto, SaveConversationLayoutInput, - SavedWorkspaceDetailDto, SavedWorkspaceSummaryDto, SendPromptInput, SessionMessageDto, - SessionSnapshotDto, SessionTodoDto, SessionTodoStatusDto, StartProviderAuthInput, - StatusCategoryDto, TerminalCloseInput, TerminalErrorEventDto, TerminalExitEventDto, - TerminalOpenInput, TerminalOutputEventDto, TerminalResizeInput, TerminalSessionDto, - TerminalWriteInput, ToolCallDetailDto, ToolResultDetailDto, UpdatePromptSettingsInput, - UpdateSavedWorkspaceLayoutInput, WorkflowDraftInput, WorkflowDraftPayloadDto, WorkflowNodeDto, - WorkspaceFileStatusDto, WorkspaceInfoPayloadDto, WorkspaceKindDto, WorkspaceQueryInput, - WorkspaceSearchPayloadDto, WorkspaceSearchResultDto, WorkspaceSessionDto, - WorkspaceStatusPayloadDto, WorkspaceSyncPayloadDto, -}; -use ts_rs::{Config, TS}; - -fn main() -> anyhow::Result<()> { - let output_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../src/services/desktop/types/contracts.generated.ts"); - let config = Config::default(); - - let declarations = [ - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - export_decl::(&config), - ]; - - let body = declarations - .into_iter() - .collect::, _>>()? - .join("\n\n"); - - let contents = format!( - "// This file is generated by `cargo run --manifest-path src-tauri/Cargo.toml --bin generate_desktop_contracts`.\n// Do not edit it by hand.\n\n{body}\n" - ); - - fs::write(&output_path, contents) - .with_context(|| format!("Failed to write {}", output_path.display()))?; - - Ok(()) -} - -fn export_decl(config: &Config) -> anyhow::Result { - let declaration = T::decl(config); - let trimmed = declaration.trim(); - let declaration = if trimmed.starts_with("export ") { - trimmed.to_string() - } else { - format!("export {trimmed}") - }; - - Ok(trim_trailing_whitespace(&declaration)) -} - -fn trim_trailing_whitespace(value: &str) -> String { - value - .lines() - .map(str::trim_end) - .collect::>() - .join("\n") -} diff --git a/gui/src-tauri/icons/128x128.png b/gui/src-tauri/icons/128x128.png deleted file mode 100644 index b6872752..00000000 Binary files a/gui/src-tauri/icons/128x128.png and /dev/null differ diff --git a/gui/src-tauri/icons/128x128@2x.png b/gui/src-tauri/icons/128x128@2x.png deleted file mode 100644 index ce297674..00000000 Binary files a/gui/src-tauri/icons/128x128@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/32x32.png b/gui/src-tauri/icons/32x32.png deleted file mode 100644 index ebe54f5b..00000000 Binary files a/gui/src-tauri/icons/32x32.png and /dev/null differ diff --git a/gui/src-tauri/icons/64x64.png b/gui/src-tauri/icons/64x64.png deleted file mode 100644 index 81eed878..00000000 Binary files a/gui/src-tauri/icons/64x64.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square107x107Logo.png b/gui/src-tauri/icons/Square107x107Logo.png deleted file mode 100644 index 41f3acdd..00000000 Binary files a/gui/src-tauri/icons/Square107x107Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square142x142Logo.png b/gui/src-tauri/icons/Square142x142Logo.png deleted file mode 100644 index f69a23c9..00000000 Binary files a/gui/src-tauri/icons/Square142x142Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square150x150Logo.png b/gui/src-tauri/icons/Square150x150Logo.png deleted file mode 100644 index 53e60556..00000000 Binary files a/gui/src-tauri/icons/Square150x150Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square284x284Logo.png b/gui/src-tauri/icons/Square284x284Logo.png deleted file mode 100644 index 5fa72d37..00000000 Binary files a/gui/src-tauri/icons/Square284x284Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square30x30Logo.png b/gui/src-tauri/icons/Square30x30Logo.png deleted file mode 100644 index a64a666e..00000000 Binary files a/gui/src-tauri/icons/Square30x30Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square310x310Logo.png b/gui/src-tauri/icons/Square310x310Logo.png deleted file mode 100644 index 8b918789..00000000 Binary files a/gui/src-tauri/icons/Square310x310Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square44x44Logo.png b/gui/src-tauri/icons/Square44x44Logo.png deleted file mode 100644 index 0ab3ca02..00000000 Binary files a/gui/src-tauri/icons/Square44x44Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square71x71Logo.png b/gui/src-tauri/icons/Square71x71Logo.png deleted file mode 100644 index 35c4adeb..00000000 Binary files a/gui/src-tauri/icons/Square71x71Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/Square89x89Logo.png b/gui/src-tauri/icons/Square89x89Logo.png deleted file mode 100644 index 69882080..00000000 Binary files a/gui/src-tauri/icons/Square89x89Logo.png and /dev/null differ diff --git a/gui/src-tauri/icons/StoreLogo.png b/gui/src-tauri/icons/StoreLogo.png deleted file mode 100644 index f625d37f..00000000 Binary files a/gui/src-tauri/icons/StoreLogo.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/gui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24b..00000000 --- a/gui/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 4a8e7348..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 6048bf47..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index cd162726..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 9bf55355..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 100f9a2b..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index a436e063..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index f0676293..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index b881278e..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index c6bc4094..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index 5aa26801..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index c4e041ee..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 5bc46bdc..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 6eac43d6..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 22cfe6ab..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 54757d13..00000000 Binary files a/gui/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/gui/src-tauri/icons/android/values/ic_launcher_background.xml b/gui/src-tauri/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223a..00000000 --- a/gui/src-tauri/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/gui/src-tauri/icons/icon-master-1024.png b/gui/src-tauri/icons/icon-master-1024.png deleted file mode 100644 index 48de25e8..00000000 Binary files a/gui/src-tauri/icons/icon-master-1024.png and /dev/null differ diff --git a/gui/src-tauri/icons/icon-no-bg.png b/gui/src-tauri/icons/icon-no-bg.png deleted file mode 100644 index d362961e..00000000 Binary files a/gui/src-tauri/icons/icon-no-bg.png and /dev/null differ diff --git a/gui/src-tauri/icons/icon-source.svg b/gui/src-tauri/icons/icon-source.svg deleted file mode 100644 index 48df10f5..00000000 --- a/gui/src-tauri/icons/icon-source.svg +++ /dev/null @@ -1,1042 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/gui/src-tauri/icons/icon.ico b/gui/src-tauri/icons/icon.ico deleted file mode 100644 index ba9cafbb..00000000 Binary files a/gui/src-tauri/icons/icon.ico and /dev/null differ diff --git a/gui/src-tauri/icons/icon.png b/gui/src-tauri/icons/icon.png deleted file mode 100644 index a1358233..00000000 Binary files a/gui/src-tauri/icons/icon.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 5b4aed8a..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index ec4e824a..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index ec4e824a..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png b/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 859e0dd9..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index f189db31..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 6798aac0..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 6798aac0..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png b/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index 21086ca3..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index ec4e824a..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index 37818223..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index 37818223..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png b/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 530650f0..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-512@2x.png b/gui/src-tauri/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 69e077a3..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png b/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 530650f0..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png b/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index d5b076c5..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png b/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index 59a47090..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png b/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 43daddc2..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index 1b3073c3..00000000 Binary files a/gui/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/gui/src-tauri/src/bridge/desktop_infra/command.rs b/gui/src-tauri/src/bridge/desktop_infra/command.rs deleted file mode 100644 index 927fae31..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/command.rs +++ /dev/null @@ -1,33 +0,0 @@ -use std::path::PathBuf; -use std::process::ExitStatus; - -use forge_app::CommandInfra; -use forge_domain::CommandOutput; - -use super::DesktopInfra; - -#[async_trait::async_trait] -impl CommandInfra for DesktopInfra { - async fn execute_command( - &self, - command: String, - working_dir: PathBuf, - silent: bool, - env_vars: Option>, - ) -> anyhow::Result { - self.inner - .execute_command(command, working_dir, silent, env_vars) - .await - } - - async fn execute_command_raw( - &self, - command: &str, - working_dir: PathBuf, - env_vars: Option>, - ) -> anyhow::Result { - self.inner - .execute_command_raw(command, working_dir, env_vars) - .await - } -} diff --git a/gui/src-tauri/src/bridge/desktop_infra/env.rs b/gui/src-tauri/src/bridge/desktop_infra/env.rs deleted file mode 100644 index efb86c1b..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/env.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::collections::BTreeMap; - -use forge_app::{EnvironmentInfra, GrpcInfra, StrategyFactory}; -use forge_domain::{AuthMethod, ProviderId, URLParamSpec}; - -use super::DesktopInfra; - -impl EnvironmentInfra for DesktopInfra { - type Config = forge_config::ForgeConfig; - - fn get_env_var(&self, key: &str) -> Option { - self.inner.get_env_var(key) - } - - fn get_env_vars(&self) -> BTreeMap { - self.inner.get_env_vars() - } - - fn get_environment(&self) -> forge_domain::Environment { - self.inner.get_environment() - } - - fn get_config(&self) -> anyhow::Result { - self.inner.get_config() - } - - async fn update_environment( - &self, - ops: Vec, - ) -> anyhow::Result<()> { - self.inner.update_environment(ops).await - } -} - -impl StrategyFactory for DesktopInfra { - type Strategy = ::Strategy; - - fn create_auth_strategy( - &self, - provider_id: ProviderId, - auth_method: AuthMethod, - required_params: Vec, - ) -> anyhow::Result { - self.inner - .create_auth_strategy(provider_id, auth_method, required_params) - } -} - -impl GrpcInfra for DesktopInfra { - fn channel(&self) -> anyhow::Result { - self.inner.channel() - } - - fn hydrate(&self) { - self.inner.hydrate(); - } -} diff --git a/gui/src-tauri/src/bridge/desktop_infra/fs.rs b/gui/src-tauri/src/bridge/desktop_infra/fs.rs deleted file mode 100644 index 0f9d9c2b..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/fs.rs +++ /dev/null @@ -1,111 +0,0 @@ -use std::path::{Path, PathBuf}; - -use bytes::Bytes; -use forge_app::{ - DirectoryReaderInfra, FileDirectoryInfra, FileInfoInfra, FileReaderInfra, FileRemoverInfra, - FileWriterInfra, WalkedFile, Walker, WalkerInfra, -}; -use forge_domain::FileInfo; - -use super::DesktopInfra; - -#[async_trait::async_trait] -impl FileReaderInfra for DesktopInfra { - async fn read_utf8(&self, path: &Path) -> anyhow::Result { - self.inner.read_utf8(path).await - } - - fn read_batch_utf8( - &self, - batch_size: usize, - paths: Vec, - ) -> impl futures::Stream)> + Send { - self.inner.read_batch_utf8(batch_size, paths) - } - - async fn read(&self, path: &Path) -> anyhow::Result> { - self.inner.read(path).await - } - - async fn range_read_utf8( - &self, - path: &Path, - start_line: u64, - end_line: u64, - ) -> anyhow::Result<(String, FileInfo)> { - self.inner.range_read_utf8(path, start_line, end_line).await - } -} - -#[async_trait::async_trait] -impl FileWriterInfra for DesktopInfra { - async fn write(&self, path: &Path, contents: Bytes) -> anyhow::Result<()> { - self.inner.write(path, contents).await - } - - async fn append(&self, path: &Path, contents: Bytes) -> anyhow::Result<()> { - self.inner.append(path, contents).await - } - - async fn write_temp(&self, prefix: &str, ext: &str, content: &str) -> anyhow::Result { - self.inner.write_temp(prefix, ext, content).await - } -} - -#[async_trait::async_trait] -impl FileRemoverInfra for DesktopInfra { - async fn remove(&self, path: &Path) -> anyhow::Result<()> { - self.inner.remove(path).await - } -} - -#[async_trait::async_trait] -impl FileInfoInfra for DesktopInfra { - async fn is_binary(&self, path: &Path) -> anyhow::Result { - self.inner.is_binary(path).await - } - - async fn is_file(&self, path: &Path) -> anyhow::Result { - self.inner.is_file(path).await - } - - async fn exists(&self, path: &Path) -> anyhow::Result { - self.inner.exists(path).await - } - - async fn file_size(&self, path: &Path) -> anyhow::Result { - self.inner.file_size(path).await - } -} - -#[async_trait::async_trait] -impl FileDirectoryInfra for DesktopInfra { - async fn create_dirs(&self, path: &Path) -> anyhow::Result<()> { - self.inner.create_dirs(path).await - } -} - -#[async_trait::async_trait] -impl DirectoryReaderInfra for DesktopInfra { - async fn list_directory_entries( - &self, - directory: &Path, - ) -> anyhow::Result> { - self.inner.list_directory_entries(directory).await - } - - async fn read_directory_files( - &self, - directory: &Path, - pattern: Option<&str>, - ) -> anyhow::Result> { - self.inner.read_directory_files(directory, pattern).await - } -} - -#[async_trait::async_trait] -impl WalkerInfra for DesktopInfra { - async fn walk(&self, config: Walker) -> anyhow::Result> { - self.inner.walk(config).await - } -} diff --git a/gui/src-tauri/src/bridge/desktop_infra/mod.rs b/gui/src-tauri/src/bridge/desktop_infra/mod.rs deleted file mode 100644 index b9818456..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/mod.rs +++ /dev/null @@ -1,23 +0,0 @@ -mod command; -mod env; -mod fs; -mod network; -mod user; - -use std::sync::Arc; - -use forge_infra::ForgeInfra; - -use crate::bridge::followup::FollowupBridge; - -#[derive(Clone)] -pub struct DesktopInfra { - pub(super) inner: ForgeInfra, - pub(super) followups: Arc, -} - -impl DesktopInfra { - pub fn new(inner: ForgeInfra, followups: Arc) -> Self { - Self { inner, followups } - } -} diff --git a/gui/src-tauri/src/bridge/desktop_infra/network.rs b/gui/src-tauri/src/bridge/desktop_infra/network.rs deleted file mode 100644 index 7227c50f..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/network.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::collections::BTreeMap; - -use bytes::Bytes; -use forge_app::{HttpInfra, McpServerInfra}; -use forge_domain::McpServerConfig; -use forge_eventsource::EventSource; -use forge_infra::ForgeInfra; -use reqwest::header::HeaderMap; -use reqwest::{Response, Url}; - -use super::DesktopInfra; - -#[async_trait::async_trait] -impl McpServerInfra for DesktopInfra { - type Client = ::Client; - - async fn connect( - &self, - config: McpServerConfig, - env_vars: &BTreeMap, - environment: &forge_domain::Environment, - ) -> anyhow::Result { - self.inner.connect(config, env_vars, environment).await - } -} - -#[async_trait::async_trait] -impl HttpInfra for DesktopInfra { - async fn http_get(&self, url: &Url, headers: Option) -> anyhow::Result { - self.inner.http_get(url, headers).await - } - - async fn http_post( - &self, - url: &Url, - headers: Option, - body: Bytes, - ) -> anyhow::Result { - self.inner.http_post(url, headers, body).await - } - - async fn http_delete(&self, url: &Url) -> anyhow::Result { - self.inner.http_delete(url).await - } - - async fn http_eventsource( - &self, - url: &Url, - headers: Option, - body: Bytes, - ) -> anyhow::Result { - self.inner.http_eventsource(url, headers, body).await - } -} diff --git a/gui/src-tauri/src/bridge/desktop_infra/user.rs b/gui/src-tauri/src/bridge/desktop_infra/user.rs deleted file mode 100644 index b793c343..00000000 --- a/gui/src-tauri/src/bridge/desktop_infra/user.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::fmt::Display; - -use forge_app::UserInfra; - -use super::DesktopInfra; - -#[async_trait::async_trait] -impl UserInfra for DesktopInfra { - async fn prompt_question(&self, question: &str) -> anyhow::Result> { - self.followups.prompt_question(question).await - } - - async fn select_one( - &self, - message: &str, - options: Vec, - ) -> anyhow::Result> { - self.followups.select_one(message, options).await - } - - async fn select_many( - &self, - message: &str, - options: Vec, - ) -> anyhow::Result>> { - self.followups.select_many(message, options).await - } -} diff --git a/gui/src-tauri/src/bridge/emitter.rs b/gui/src-tauri/src/bridge/emitter.rs deleted file mode 100644 index bdc7e3cf..00000000 --- a/gui/src-tauri/src/bridge/emitter.rs +++ /dev/null @@ -1,63 +0,0 @@ -use anyhow::Result; -use tauri::Emitter; - -use crate::dto::{ - ProviderOAuthCallbackDto, SessionSnapshotDto, TerminalErrorEventDto, TerminalExitEventDto, - TerminalOutputEventDto, -}; - -pub const SESSION_UPDATED_EVENT_NAME: &str = "codegraff://session-updated"; -pub const TERMINAL_OUTPUT_EVENT_NAME: &str = "codegraff://terminal-output"; -pub const TERMINAL_EXIT_EVENT_NAME: &str = "codegraff://terminal-exit"; -pub const TERMINAL_ERROR_EVENT_NAME: &str = "codegraff://terminal-error"; -// OAuth-callback bridge is copied scaffolding; the simple adapter uses device / -// api-key flows, so this isn't emitted yet. Retained for future parity. -#[allow(dead_code)] -pub const PROVIDER_OAUTH_CALLBACK_EVENT_NAME: &str = "codegraff://provider-oauth-callback"; - -pub trait UiEventEmitter: Send + Sync { - fn emit_session_updated(&self, payload: SessionSnapshotDto) -> Result<()>; - fn emit_terminal_output(&self, payload: TerminalOutputEventDto) -> Result<()>; - fn emit_terminal_exit(&self, payload: TerminalExitEventDto) -> Result<()>; - fn emit_terminal_error(&self, payload: TerminalErrorEventDto) -> Result<()>; - #[allow(dead_code)] - fn emit_provider_oauth_callback(&self, payload: ProviderOAuthCallbackDto) -> Result<()>; -} - -#[derive(Clone)] -pub struct TauriEventEmitter { - app: tauri::AppHandle, -} - -impl TauriEventEmitter { - pub fn new(app: tauri::AppHandle) -> Self { - Self { app } - } -} - -impl UiEventEmitter for TauriEventEmitter { - fn emit_session_updated(&self, payload: SessionSnapshotDto) -> Result<()> { - self.app.emit(SESSION_UPDATED_EVENT_NAME, payload)?; - Ok(()) - } - - fn emit_terminal_output(&self, payload: TerminalOutputEventDto) -> Result<()> { - self.app.emit(TERMINAL_OUTPUT_EVENT_NAME, payload)?; - Ok(()) - } - - fn emit_terminal_exit(&self, payload: TerminalExitEventDto) -> Result<()> { - self.app.emit(TERMINAL_EXIT_EVENT_NAME, payload)?; - Ok(()) - } - - fn emit_terminal_error(&self, payload: TerminalErrorEventDto) -> Result<()> { - self.app.emit(TERMINAL_ERROR_EVENT_NAME, payload)?; - Ok(()) - } - - fn emit_provider_oauth_callback(&self, payload: ProviderOAuthCallbackDto) -> Result<()> { - self.app.emit(PROVIDER_OAUTH_CALLBACK_EVENT_NAME, payload)?; - Ok(()) - } -} diff --git a/gui/src-tauri/src/bridge/followup.rs b/gui/src-tauri/src/bridge/followup.rs deleted file mode 100644 index 70f8cd56..00000000 --- a/gui/src-tauri/src/bridge/followup.rs +++ /dev/null @@ -1,206 +0,0 @@ -use std::collections::HashMap; -use std::fmt::Display; -use std::future::Future; - -use anyhow::Context; -use tokio::sync::{Mutex, mpsc, oneshot}; -use uuid::Uuid; - -use crate::dto::{FollowupKind, FollowupOptionDto, FollowupRequestDto, FollowupResponseDto}; - -tokio::task_local! { - static FOLLOWUP_CONTEXT: FollowupContext; -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct FollowupContext { - pub workspace_path: String, - pub conversation_id: String, - pub request_id: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct FollowupResolution { - cancelled: bool, - text: Option, - selected_option_ids: Vec, -} - -pub struct FollowupBridge { - notifications: mpsc::UnboundedSender, - pending: Mutex>>, -} - -impl FollowupBridge { - pub fn new(notifications: mpsc::UnboundedSender) -> Self { - Self { - notifications, - pending: Mutex::new(HashMap::new()), - } - } - - pub async fn prompt_question(&self, question: &str) -> anyhow::Result> { - let response = self - .request(FollowupKind::Text, question.to_string(), None) - .await?; - - if response.cancelled { - return Ok(None); - } - - Ok(response.text.filter(|value| !value.trim().is_empty())) - } - - pub async fn select_one(&self, message: &str, options: Vec) -> anyhow::Result> - where - T: Clone + Display + Send + 'static, - { - let mapped = map_options(options); - let response = self - .request( - FollowupKind::Single, - message.to_string(), - Some(mapped.iter().map(|(_, option, _)| option.clone()).collect()), - ) - .await?; - - if response.cancelled { - return Ok(None); - } - - let Some(selected_id) = response.selected_option_ids.first() else { - return Ok(None); - }; - - Ok(mapped - .into_iter() - .find_map(|(id, _, option)| (id == *selected_id).then_some(option))) - } - - pub async fn select_many( - &self, - message: &str, - options: Vec, - ) -> anyhow::Result>> - where - T: Clone + Display + Send + 'static, - { - let mapped = map_options(options); - let response = self - .request( - FollowupKind::Multi, - message.to_string(), - Some(mapped.iter().map(|(_, option, _)| option.clone()).collect()), - ) - .await?; - - if response.cancelled { - return Ok(None); - } - - let selected = mapped - .into_iter() - .filter_map(|(id, _, option)| { - response.selected_option_ids.contains(&id).then_some(option) - }) - .collect::>(); - - Ok(Some(selected)) - } - - pub async fn respond(&self, response: FollowupResponseDto) -> anyhow::Result<()> { - let sender = self - .pending - .lock() - .await - .remove(&response.followup_id) - .with_context(|| format!("Unknown follow-up request: {}", response.followup_id))?; - - sender - .send(FollowupResolution { - cancelled: response.cancelled, - text: response.text, - selected_option_ids: response.selected_option_ids.unwrap_or_default(), - }) - .map_err(|_| anyhow::anyhow!("Follow-up receiver dropped"))?; - - Ok(()) - } - - pub async fn cancel_all(&self) { - let pending = std::mem::take(&mut *self.pending.lock().await); - for (_, sender) in pending { - let _ = sender.send(cancelled_resolution()); - } - } - - async fn request( - &self, - kind: FollowupKind, - question: String, - options: Option>, - ) -> anyhow::Result { - let context = FOLLOWUP_CONTEXT - .try_with(|current| current.clone()) - .map_err(|_| anyhow::anyhow!("Follow-up requested outside an active chat context."))?; - let followup_id = Uuid::new_v4().to_string(); - let (sender, receiver) = oneshot::channel(); - self.pending - .lock() - .await - .insert(followup_id.clone(), sender); - - let payload = FollowupRequestDto { - followup_id: followup_id.clone(), - workspace_path: context.workspace_path, - conversation_id: context.conversation_id, - request_id: context.request_id, - kind, - question, - options, - }; - - if let Err(error) = self.notifications.send(payload) { - self.pending.lock().await.remove(&followup_id); - return Err(anyhow::anyhow!(error.to_string())); - } - - Ok(receiver.await.unwrap_or_else(|_| cancelled_resolution())) - } -} - -pub async fn with_followup_context(context: FollowupContext, future: F) -> T -where - F: Future, -{ - FOLLOWUP_CONTEXT.scope(context, future).await -} - -fn map_options(options: Vec) -> Vec<(String, FollowupOptionDto, T)> -where - T: Clone + Display + Send + 'static, -{ - options - .into_iter() - .enumerate() - .map(|(index, option)| { - let id = format!("option-{index}"); - ( - id.clone(), - FollowupOptionDto { - id, - label: option.to_string(), - }, - option, - ) - }) - .collect() -} - -fn cancelled_resolution() -> FollowupResolution { - FollowupResolution { - cancelled: true, - text: None, - selected_option_ids: Vec::new(), - } -} diff --git a/gui/src-tauri/src/cli_install.rs b/gui/src-tauri/src/cli_install.rs deleted file mode 100644 index 665b44b9..00000000 --- a/gui/src-tauri/src/cli_install.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! First-run install of the `codegraff` launcher command (VS Code `code`-style). -//! -//! Writes a small shell script onto the user's PATH so `codegraff ` opens -//! that folder in the Codegraff desktop app, and bare `codegraff` opens the app. -//! The script launches this app's own binary detached; the single-instance -//! plugin forwards the path to an already-running window (see `lib.rs`). -//! -//! Best-effort by design: it never fails app startup, and is a no-op in dev -//! (it only acts for an installed `.app` bundle). Prefers `/usr/local/bin`, -//! falling back to `~/.local/bin` when that isn't writable. - -#[cfg(target_os = "macos")] -pub fn ensure_cli_symlink(app: &tauri::App) { - use std::io::Write; - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - use tauri::Manager; - - // …/Codegraff.app/Contents/MacOS/codegraff-gui — the binary the launcher runs. - let exe = match std::env::current_exe() { - Ok(p) => p, - Err(_) => return, - }; - // Only install from a real bundle; skip `cargo tauri dev` runs out of target/. - if !exe.to_string_lossy().contains(".app/Contents/MacOS/") { - return; - } - - // `code`-style launcher. Resolves the first arg to an absolute path and - // launches the app detached (nohup + &) so the terminal returns immediately. - let script = format!( - "#!/bin/sh\n\ - # codegraff — open a path in the Codegraff desktop app (code-style).\n\ - # Auto-generated by Codegraff on launch; safe to delete.\n\ - APP_BIN=\"{exe}\"\n\ - if [ \"$#\" -eq 0 ]; then\n\ - \x20 nohup \"$APP_BIN\" >/dev/null 2>&1 &\n\ - \x20 exit 0\n\ - fi\n\ - target=\"$1\"\n\ - if [ -e \"$target\" ]; then\n\ - \x20 target=\"$(cd \"$(dirname \"$target\")\" 2>/dev/null && pwd)/$(basename \"$target\")\"\n\ - fi\n\ - nohup \"$APP_BIN\" \"$target\" >/dev/null 2>&1 &\n", - exe = exe.display() - ); - - let mut targets: Vec = vec![PathBuf::from("/usr/local/bin")]; - if let Ok(home) = app.path().home_dir() { - targets.push(home.join(".local").join("bin")); - } - - // The bundled agent CLI ships beside the app binary (externalBin → - // Contents/MacOS/graff). We link it onto PATH so installing the app also - // installs the `graff` terminal command. - let bundled_graff = exe.parent().map(|d| d.join("graff")); - - for dir in targets { - let link = dir.join("codegraff"); - // Already current (handles app moves and the prior symlink form)? - if let Ok(existing) = std::fs::read_to_string(&link) { - if existing == script { - log::info!("codegraff launcher already current at {}", link.display()); - if let Some(ref g) = bundled_graff { - ensure_graff_on_path(&dir, g); - } - return; - } - } - if std::fs::create_dir_all(&dir).is_err() { - continue; - } - // Clear any stale file/symlink (e.g. the 0.0.11 binary symlink). - let _ = std::fs::remove_file(&link); - let written = (|| -> std::io::Result<()> { - let mut f = std::fs::File::create(&link)?; - f.write_all(script.as_bytes())?; - f.set_permissions(std::fs::Permissions::from_mode(0o755))?; - Ok(()) - })(); - match written { - Ok(()) => { - log::info!("codegraff launcher installed at {}", link.display()); - if let Some(ref g) = bundled_graff { - ensure_graff_on_path(&dir, g); - } - return; - } - Err(err) => { - log::warn!("could not write codegraff launcher into {}: {err}", dir.display()); - } - } - } -} - -/// Put the bundled `graff` agent CLI on PATH (next to the `codegraff` launcher), -/// so installing the desktop app also gives you the terminal command. It's a -/// symlink into the app bundle, so `graff` tracks the app's auto-updates. -/// -/// Never clobbers a real `graff` — e.g. one installed by `install.sh` into ~/bin -/// or a Homebrew copy. Only creates the link when nothing is there, or refreshes -/// a symlink we previously made (one pointing into a .app bundle, e.g. after the -/// app was moved or updated). -#[cfg(target_os = "macos")] -fn ensure_graff_on_path(dir: &std::path::Path, bundled_graff: &std::path::Path) { - use std::os::unix::fs::symlink; - if !bundled_graff.is_file() { - return; - } - let link = dir.join("graff"); - match std::fs::symlink_metadata(&link) { - Ok(meta) => { - if !meta.file_type().is_symlink() { - // A regular file is a real install — never overwrite it. - return; - } - let current = std::fs::read_link(&link).unwrap_or_default(); - if current == bundled_graff { - return; // already correct - } - if !current.to_string_lossy().contains(".app/Contents/MacOS/") { - return; // a symlink to something that isn't ours — leave it - } - let _ = std::fs::remove_file(&link); - } - Err(_) => {} // absent — fall through and create it - } - if symlink(bundled_graff, &link).is_ok() { - log::info!("graff CLI linked at {}", link.display()); - } -} - -#[cfg(not(target_os = "macos"))] -pub fn ensure_cli_symlink(_app: &tauri::App) {} diff --git a/gui/src-tauri/src/commands.rs b/gui/src-tauri/src/commands.rs deleted file mode 100644 index 905bdb04..00000000 --- a/gui/src-tauri/src/commands.rs +++ /dev/null @@ -1,958 +0,0 @@ -use crate::dto::{ - AgentsPayloadDto, ChatBindingDto, CheckoutGitBranchInput, CloneRepositoryInput, - CommandDescriptorDto, CommandRunResultDto, CommitGitChangesInput, CompleteProviderAuthInput, - CreateGitBranchInput, CreateSavedWorkspaceInput, FollowupResponseDto, HandoffChatInput, - McpImportInput, McpServerActionInput, McpSettingsPayloadDto, PromptSettingsDto, - ProviderAuthSessionDto, ProviderSummaryDto, QuickStartProjectInput, QuickStartVisibility, - RemoveProviderInput, RuntimeStatusDto, SaveConversationLayoutInput, SendPromptInput, - SessionSnapshotDto, StartProviderAuthInput, TerminalCloseInput, TerminalOpenInput, - TerminalResizeInput, TerminalSessionDto, TerminalWriteInput, UpdatePromptSettingsInput, - UpdateSavedWorkspaceLayoutInput, WorkflowDraftInput, WorkflowDraftPayloadDto, - WorkspaceQueryInput, WorkspaceSearchPayloadDto, WorkspaceSyncPayloadDto, -}; -use crate::runtime::{DesktopState, format_error_chain}; -use anyhow::Context; -use bstr::ByteSlice; -use std::path::{Path, PathBuf}; -use std::process::Command; -use tauri_plugin_dialog::{DialogExt, FilePath}; - -fn map_command_error(error: anyhow::Error) -> String { - format_error_chain(&error) -} - -#[tauri::command] -pub(crate) async fn pick_workspace(app: tauri::AppHandle) -> Result, String> { - let selected = app - .dialog() - .file() - .set_title("Open project folder") - .blocking_pick_folder(); - - Ok(selected.and_then(file_path_to_string)) -} - -#[tauri::command] -pub(crate) async fn pick_directory( - app: tauri::AppHandle, - title: Option, -) -> Result, String> { - let mut dialog = app.dialog().file(); - if let Some(title) = title { - dialog = dialog.set_title(&title); - } - - Ok(dialog.blocking_pick_folder().and_then(file_path_to_string)) -} - -#[tauri::command] -pub(crate) async fn open_workspace( - path: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .open_workspace(path.into()) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn get_runtime_status( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .get_runtime_status(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn get_session_snapshot( - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .get_session_snapshot() - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn get_prompt_settings( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .get_prompt_settings(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn list_providers( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result, String> { - state - .manager - .list_providers(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn start_provider_auth( - input: StartProviderAuthInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .start_provider_auth(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn complete_provider_auth( - input: CompleteProviderAuthInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .complete_provider_auth(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn remove_provider( - input: RemoveProviderInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .remove_provider(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn list_commands( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result, String> { - state - .manager - .list_commands(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn send_prompt( - input: SendPromptInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .send_prompt(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn run_slash_command( - name: String, - args: Vec, - workspace_path: Option, - conversation_id: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .run_command(name, args, workspace_path, conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn workspace_sync( - workspace_path: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .workspace_sync(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn workspace_query( - input: WorkspaceQueryInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .workspace_query(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn build_workflow_draft( - input: WorkflowDraftInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .build_workflow_draft(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) fn export_workflow_draft( - draft: WorkflowDraftPayloadDto, - state: tauri::State<'_, DesktopState>, -) -> String { - state.manager.export_workflow_draft(draft) -} - -#[tauri::command] -pub(crate) async fn set_effort( - level: String, - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .set_effort(level, workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn set_fast( - on: bool, - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .set_fast(on, workspace_path) - .await - .map_err(map_command_error) -} - -/// Persists a clipboard-pasted image to a temp file and returns its path, so it -/// can flow through the same attachment pipeline as drag-dropped files (the -/// harness reads image attachments by path). Used by Cmd/Ctrl+V in the composer. -#[tauri::command] -pub(crate) async fn save_pasted_image(data: Vec, ext: String) -> Result { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - let n = COUNTER.fetch_add(1, Ordering::Relaxed); - let safe_ext = match ext.to_lowercase().as_str() { - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "avif" => ext.to_lowercase(), - _ => "png".to_string(), - }; - let dir = std::env::temp_dir().join("codegraff-pasted"); - std::fs::create_dir_all(&dir).map_err(|error| error.to_string())?; - let path = dir.join(format!("paste-{}-{n}.{safe_ext}", std::process::id())); - std::fs::write(&path, &data).map_err(|error| error.to_string())?; - Ok(path.to_string_lossy().into_owned()) -} - -/// Decodes an image, downscales it to fit `max_dim` (default 96px, aspect -/// preserved), and re-encodes it as a compressed JPEG returned as a data URL — -/// so the composer can show a real thumbnail without loading the full file into -/// the webview. Runs the CPU-bound work off the async runtime. -#[tauri::command] -pub(crate) async fn image_thumbnail(path: String, max_dim: Option) -> Result { - let max = max_dim.unwrap_or(96).clamp(16, 512); - tokio::task::spawn_blocking(move || { - use base64::Engine as _; - let img = image::open(&path).map_err(|error| error.to_string())?; - // `thumbnail` is a fast box filter that preserves aspect ratio. - let rgb = img.thumbnail(max, max).to_rgb8(); - let mut buf = std::io::Cursor::new(Vec::new()); - image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 70) - .encode_image(&rgb) - .map_err(|error| error.to_string())?; - let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref()); - Ok::(format!("data:image/jpeg;base64,{b64}")) - }) - .await - .map_err(|error| error.to_string())? -} - -#[tauri::command] -pub(crate) async fn set_active_agent( - agent_id: String, - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .set_active_agent(agent_id, workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn list_mcp_servers( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .list_mcp_servers(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn import_mcp_config( - input: McpImportInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .import_mcp_config(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn remove_mcp_server( - input: McpServerActionInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .remove_mcp_server(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn reload_mcp_servers( - workspace_path: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .reload_mcp_servers(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn login_mcp_server( - input: McpServerActionInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .login_mcp_server(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn logout_mcp_server( - input: McpServerActionInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .logout_mcp_server(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn compact_conversation( - input: ChatBindingDto, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .compact_conversation(input.workspace_path, input.conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn stop_prompt( - input: ChatBindingDto, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .stop_prompt(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn update_prompt_settings( - input: UpdatePromptSettingsInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .update_prompt_settings(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn select_conversation( - workspace_path: String, - conversation_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .select_conversation(workspace_path, conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn ensure_conversation_view( - workspace_path: String, - conversation_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .ensure_conversation_view(workspace_path, conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn start_new_chat( - workspace_path: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .start_new_chat(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn create_managed_chat( - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .create_managed_chat() - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn handoff_chat( - input: HandoffChatInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .handoff_chat(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn respond_followup( - response: FollowupResponseDto, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .respond_followup(response) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn archive_conversation( - workspace_path: String, - conversation_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .archive_conversation(workspace_path, conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn archive_workspace( - workspace_path: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .archive_workspace(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn rename_workspace( - workspace_path: String, - display_name: Option, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .rename_workspace(workspace_path, display_name) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn clone_repository(input: CloneRepositoryInput) -> Result { - clone_repository_to_directory(input).map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn quick_start_project(input: QuickStartProjectInput) -> Result { - create_github_project(input).map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn checkout_git_branch( - input: CheckoutGitBranchInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .checkout_git_branch(input.workspace_path, input.branch_name) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn create_git_branch( - input: CreateGitBranchInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .create_git_branch(input.workspace_path, input.branch_name) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn commit_git_changes( - input: CommitGitChangesInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .commit_git_changes(input.workspace_path, input.message) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn push_git_branch( - workspace_path: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .push_git_branch(workspace_path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn open_in_target( - workspace_path: String, - target_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .open_in_target(workspace_path, target_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn open_path_in_target( - workspace_path: String, - target_id: String, - path: String, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .open_path_in_target(workspace_path, target_id, path) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn open_external_url(url: String) -> Result<(), String> { - crate::desktop_open::open_external_url(&url).map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn open_path_default(path: String) -> Result<(), String> { - crate::desktop_open::open_path_default(std::path::Path::new(&path)).map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn open_path_for_edit(path: String) -> Result<(), String> { - crate::desktop_open::open_path_for_edit(std::path::Path::new(&path)).map_err(map_command_error) -} - -/// Reads a workspace file's current contents so the GUI can synthesize a diff -/// for operations the harness only reports as a byte-count summary (file -/// creates/overwrites). Confined to the workspace and capped in size; large or -/// out-of-tree paths return an error so the caller falls back to a placeholder. -#[tauri::command] -pub(crate) async fn read_workspace_file( - workspace_path: String, - path: String, -) -> Result { - // The filesystem work (canonicalize, metadata, read) is synchronous and can - // block — run it off the async runtime, like `image_thumbnail`. - tokio::task::spawn_blocking(move || read_workspace_file_blocking(&workspace_path, &path)) - .await - .map_err(|error| error.to_string())? -} - -fn read_workspace_file_blocking(workspace_path: &str, path: &str) -> Result { - use std::io::Read; - const MAX_BYTES: u64 = 512 * 1024; - - let workspace = Path::new(workspace_path); - // Reject an empty/blank workspace root. The containment check relies on - // canonicalizing a real workspace directory, and `Path::starts_with("")` - // matches every path — so an empty root would bypass the sandbox entirely. - if workspace_path.trim().is_empty() { - return Err("path is outside the workspace".into()); - } - // Fail closed: if the workspace root can't be canonicalized (it doesn't - // exist or isn't a directory) refuse rather than falling back to the raw, - // untrusted string. Falling back would let a non-existent/empty workspace - // defeat the `starts_with` containment check. - let canonical_workspace = std::fs::canonicalize(workspace) - .map_err(|_| "path is outside the workspace".to_string())?; - - let candidate = Path::new(path); - let resolved = if candidate.is_absolute() { - candidate.to_path_buf() - } else { - canonical_workspace.join(candidate) - }; - - // Canonicalize the target so `..` segments and symlinks resolve before the - // containment check. A missing target is an error, never a leak. - let canonical = std::fs::canonicalize(&resolved).map_err(|error| error.to_string())?; - if !canonical.starts_with(&canonical_workspace) { - return Err("path is outside the workspace".into()); - } - - // Only read regular files: a FIFO/socket/device reports length 0 (passing - // the size cap) but would block the read indefinitely, hanging the command - // and the "Loading diff…" card forever. - let metadata = std::fs::metadata(&canonical).map_err(|error| error.to_string())?; - if !metadata.is_file() { - return Err("path is not a regular file".into()); - } - if metadata.len() > MAX_BYTES { - return Err("file too large".into()); - } - - // Bounded read closes the TOCTOU between the size check above and the read: - // if the file grows (or the stat lied) we still cap memory at MAX_BYTES. - let mut file = std::fs::File::open(&canonical).map_err(|error| error.to_string())?; - let mut contents = String::new(); - file.by_ref() - .take(MAX_BYTES) - .read_to_string(&mut contents) - .map_err(|error| error.to_string())?; - Ok(contents) -} - -#[tauri::command] -pub(crate) async fn save_conversation_layout( - input: SaveConversationLayoutInput, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .manager - .save_conversation_layout(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn get_conversation_layout( - conversation_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result, String> { - state - .manager - .get_conversation_layout(conversation_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn create_saved_workspace( - input: CreateSavedWorkspaceInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .create_saved_workspace(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn update_saved_workspace_layout( - input: UpdateSavedWorkspaceLayoutInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .update_saved_workspace_layout(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn get_saved_workspace( - workspace_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result, String> { - state - .manager - .get_saved_workspace(workspace_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn rename_saved_workspace( - workspace_id: String, - name: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .rename_saved_workspace(workspace_id, name) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn delete_saved_workspace( - workspace_id: String, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .manager - .delete_saved_workspace(workspace_id) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn terminal_open( - input: TerminalOpenInput, - state: tauri::State<'_, DesktopState>, -) -> Result { - state - .terminal_manager - .open(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn terminal_write( - input: TerminalWriteInput, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .terminal_manager - .write(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn terminal_resize( - input: TerminalResizeInput, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .terminal_manager - .resize(input) - .await - .map_err(map_command_error) -} - -#[tauri::command] -pub(crate) async fn terminal_close( - input: TerminalCloseInput, - state: tauri::State<'_, DesktopState>, -) -> Result<(), String> { - state - .terminal_manager - .close(input) - .await - .map_err(map_command_error) -} -fn file_path_to_string(path: FilePath) -> Option { - match path { - FilePath::Path(path) => Some(path.to_string_lossy().into_owned()), - FilePath::Url(url) => url - .to_file_path() - .ok() - .map(|path| path.to_string_lossy().into_owned()) - .or_else(|| Some(url.to_string())), - } -} - -fn clone_repository_to_directory(input: CloneRepositoryInput) -> anyhow::Result { - let repository_url = input.repository_url.trim(); - if repository_url.is_empty() { - anyhow::bail!("Repository URL cannot be empty."); - } - - let parent_directory = canonicalize_existing_directory(&input.parent_directory)?; - let directory_name = validate_directory_name(&input.directory_name)?; - let target_directory = parent_directory.join(&directory_name); - ensure_directory_is_available(&target_directory)?; - - let mut command = Command::new("git"); - command - .arg("clone") - .arg(repository_url) - .arg(&target_directory) - .current_dir(&parent_directory); - run_command(&mut command, "git clone")?; - - canonicalize_output_directory(&target_directory) -} - -fn create_github_project(input: QuickStartProjectInput) -> anyhow::Result { - let parent_directory = canonicalize_existing_directory(&input.parent_directory)?; - let project_name = validate_directory_name(&input.project_name)?; - let target_directory = parent_directory.join(&project_name); - ensure_directory_is_available(&target_directory)?; - - let visibility_flag = match input.visibility { - QuickStartVisibility::Public => "--public", - QuickStartVisibility::Private => "--private", - }; - - let mut command = Command::new("gh"); - command - .arg("repo") - .arg("create") - .arg(&project_name) - .arg(visibility_flag) - .arg("--clone") - .arg("--add-readme") - .current_dir(&parent_directory); - run_command(&mut command, "gh repo create")?; - - canonicalize_output_directory(&target_directory) -} - -fn canonicalize_existing_directory(path: &str) -> anyhow::Result { - let candidate = PathBuf::from(path.trim()); - if path.trim().is_empty() { - anyhow::bail!("Directory cannot be empty."); - } - - if !candidate.exists() { - anyhow::bail!("Directory does not exist: {}", candidate.display()); - } - - if !candidate.is_dir() { - anyhow::bail!("Expected a directory: {}", candidate.display()); - } - - candidate - .canonicalize() - .with_context(|| format!("Failed to access {}", candidate.display())) -} - -fn canonicalize_output_directory(path: &Path) -> anyhow::Result { - Ok(path - .canonicalize() - .with_context(|| format!("Failed to access {}", path.display()))? - .to_string_lossy() - .into_owned()) -} - -fn validate_directory_name(value: &str) -> anyhow::Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("Name cannot be empty."); - } - - if trimmed == "." || trimmed == ".." { - anyhow::bail!("Choose a different name."); - } - - if trimmed.contains('/') || trimmed.contains('\\') { - anyhow::bail!("Name cannot contain path separators."); - } - - Ok(trimmed.to_string()) -} - -fn ensure_directory_is_available(path: &Path) -> anyhow::Result<()> { - if path.exists() { - anyhow::bail!("{} already exists.", path.display()); - } - - Ok(()) -} - -fn run_command(command: &mut Command, description: &str) -> anyhow::Result<()> { - let output = command - .output() - .with_context(|| format!("Failed to launch {description}. Make sure it is installed."))?; - - if output.status.success() { - return Ok(()); - } - - let stderr = output.stderr.as_bstr().trim().to_str_lossy().into_owned(); - let stdout = output.stdout.as_bstr().trim().to_str_lossy().into_owned(); - let details = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("{description} exited with status {}", output.status) - }; - - anyhow::bail!("{details}") -} diff --git a/gui/src-tauri/src/debug_bridge.rs b/gui/src-tauri/src/debug_bridge.rs deleted file mode 100644 index a2f0a474..00000000 --- a/gui/src-tauri/src/debug_bridge.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Dev-only debug bridge: a localhost HTTP server that lets an external agent -//! (or a test harness) drive the *native* Tauri webview — evaluate JS with -//! return values, capture the real window, and synthesize input. This is the -//! "agentic remote control" for the desktop app, mirroring what a CDP session -//! gives you for a browser tab. -//! -//! Gate: the HTTP server only starts in debug builds (`cfg(debug_assertions)`). -//! The command + type are always compiled so the `invoke_handler!` list is -//! stable across configurations; in release the command is a harmless no-op -//! (nothing is ever pending because the server never runs). - -use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; -use std::time::Duration; - -use tauri::{AppHandle, Manager}; - -const PORT: u16 = 9233; -const EVAL_TIMEOUT: Duration = Duration::from_secs(8); - -#[derive(Debug)] -struct EvalResult { - ok: bool, - value: Option, - error: Option, -} - -/// Shared state holding pending eval requests and a handle to the app. Cheap to -/// clone (Arc-backed); one copy lives in Tauri's managed state (so the -/// `cg_debug_eval_result` command can resolve requests), another in the server -/// thread. -#[derive(Clone)] -pub struct DebugBridge { - app: AppHandle, - pending: Arc>>>, - next_id: Arc, -} - -impl DebugBridge { - pub fn new(app: AppHandle) -> Self { - Self { - app, - pending: Arc::new(Mutex::new(HashMap::new())), - next_id: Arc::new(AtomicU64::new(1)), - } - } - - /// Invoked from the webview (via `__TAURI_INTERNALS__.invoke`) to return an - /// eval result. No-op when nothing is pending (e.g. release builds). - pub fn resolve(&self, id: u64, ok: bool, value: Option, error: Option) { - let sender = self - .pending - .lock() - .ok() - .and_then(|mut map| map.remove(&id)); - if let Some(tx) = sender { - let _ = tx.send(EvalResult { ok, value, error }); - } - } - - /// Launch the HTTP server. Only does something in debug builds. - pub fn start(self) { - #[cfg(debug_assertions)] - { - std::thread::spawn(move || run_server(self)); - } - #[cfg(not(debug_assertions))] - { - let _ = self; - } - } -} - -/// Tauri command the webview calls to deliver an eval result back to Rust. -/// Always registered; harmless when the bridge isn't running. -#[tauri::command] -#[allow(dead_code)] -pub fn cg_debug_eval_result( - id: u64, - ok: bool, - value: Option, - error: Option, - state: tauri::State<'_, DebugBridge>, -) { - state.resolve(id, ok, value, error); -} - -#[cfg(debug_assertions)] -fn run_server(bridge: DebugBridge) { - let server = match tiny_http::Server::http(format!("127.0.0.1:{PORT}")) { - Ok(s) => s, - Err(error) => { - log::error!("debug bridge failed to bind 127.0.0.1:{PORT}: {error}"); - return; - } - }; - log::info!("debug bridge listening on http://127.0.0.1:{PORT}"); - - for mut request in server.incoming_requests() { - let method = request.method().as_str().to_string(); - let url = request.url().to_string(); - let response = handle_request(&method, &url, &mut request, &bridge); - let _ = request.respond(response); - } -} - -#[cfg(debug_assertions)] -fn handle_request( - method: &str, - url: &str, - request: &mut tiny_http::Request, - bridge: &DebugBridge, -) -> tiny_http::Response>> { - match (method, url) { - ("GET", "/health") => json_response(200, r#"{"ok":true}"#), - ("POST", "/eval-result") => { - let mut body = String::new(); - if request.as_reader().read_to_string(&mut body).is_err() { - return json_response(400, r#"{"ok":false}"#); - } - if let Ok(parsed) = serde_json::from_str::(&body) { - let id = parsed.get("id").and_then(|v| v.as_u64()).unwrap_or(0); - let ok = parsed.get("ok").and_then(|v| v.as_bool()).unwrap_or(false); - let value = parsed - .get("value") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let error = parsed - .get("error") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - bridge.resolve(id, ok, value, error); - } - json_response(200, r#"{"ok":true}"#) - } - ("POST", "/eval") => { - let mut body = String::new(); - if request.as_reader().read_to_string(&mut body).is_err() { - return json_response(400, r#"{"ok":false,"error":"bad body"}"#); - } - let parsed: serde_json::Value = match serde_json::from_str(&body) { - Ok(v) => v, - Err(_) => return json_response(400, r#"{"ok":false,"error":"bad json"}"#), - }; - let code = match parsed.get("code").and_then(|v| v.as_str()) { - Some(c) => c, - None => { - return json_response(400, r#"{"ok":false,"error":"missing code"}"#) - } - }; - handle_eval(bridge, code) - } - ("GET", "/screenshot") => match capture_screenshot(&bridge.app) { - Ok(bytes) => tiny_http::Response::from_data(bytes) - .with_header(tiny_http::Header::from_bytes( - b"Content-Type".as_ref(), - b"image/png".as_ref(), - ) - .unwrap()), - Err(error) => json_response( - 500, - &format!(r#"{{"ok":false,"error":{}}}"#, serde_json::json!(error)), - ), - }, - _ => json_response(404, r#"{"ok":false,"error":"not found"}"#), - } -} - -#[cfg(debug_assertions)] -fn handle_eval(bridge: &DebugBridge, code: &str) -> tiny_http::Response>> { - let id = bridge.next_id.fetch_add(1, Ordering::Relaxed); - let (tx, rx) = mpsc::channel(); - if let Ok(mut map) = bridge.pending.lock() { - map.insert(id, tx); - } else { - return json_response(500, r#"{"ok":false,"error":"state lock"}"#); - } - - // Inject the user's code directly (no inner `eval()` — Tauri's default CSP - // blocks `eval`/`new Function`, so we inline the code as the body of an - // async arrow that evaluates it as an expression). The result is returned - // via Tauri's native IPC (`__TAURI_INTERNALS__.invoke`), which is CSP-exempt - // — unlike `fetch`, native invoke always works. The value is - // JSON.stringified in JS so any serializable DOM state round-trips cleanly. - // NB: `code` is inlined raw, so it must be a balanced expression (wrap - // statements in an IIFE). - let js = format!( - "(async()=>{{const id={id};const send=(o)=>{{try{{window.__TAURI_INTERNALS__.invoke('cg_debug_eval_result',o)}}catch(_){{}}}};try{{const r=await (async()=>({user_code}))();let v;try{{v=JSON.stringify(r)}}catch(_){{v=String(r)}}send({{id,ok:true,value:v}})}}catch(e){{send({{id,ok:false,error:(e&&e.stack)||String(e)}})}}}})()", - id = id, - user_code = code, - ); - - let window = bridge.app.get_webview_window("main"); - if window.is_none() { - drop_pending(bridge, id); - return json_response(503, r#"{"ok":false,"error":"no main window"}"#); - } - log::info!("[debug-bridge] eval id={id}"); - if let Err(error) = window.unwrap().eval(&js) { - drop_pending(bridge, id); - return json_response( - 500, - &format!(r#"{{"ok":false,"error":{}}}"#, serde_json::json!(error.to_string())), - ); - } - - match rx.recv_timeout(EVAL_TIMEOUT) { - Ok(result) => { - let body = serde_json::json!({ - "ok": result.ok, - "value": result.value, - "error": result.error, - }); - json_response(200, &body.to_string()) - } - Err(_) => { - drop_pending(bridge, id); - json_response(504, r#"{"ok":false,"error":"eval timeout"}"#) - } - } -} - -#[cfg(debug_assertions)] -fn drop_pending(bridge: &DebugBridge, id: u64) { - if let Ok(mut map) = bridge.pending.lock() { - map.remove(&id); - } -} - -/// Capture the main window: focus it, grab the full screen via `screencapture`, -/// then crop to the window's physical bounds (which match screencapture's pixel -/// space, even on retina). Uses only the already-vendored `image` crate. -#[cfg(debug_assertions)] -fn capture_screenshot(app: &AppHandle) -> Result, String> { - let window = app - .get_webview_window("main") - .ok_or_else(|| "no main window".to_string())?; - let _ = window.set_focus(); - std::thread::sleep(Duration::from_millis(150)); - - let pos = window.outer_position().map_err(|e| e.to_string())?; - let size = window.outer_size().map_err(|e| e.to_string())?; - let x = pos.x.max(0) as u32; - let y = pos.y.max(0) as u32; - let w = size.width; - let h = size.height; - - let tmp = std::env::temp_dir().join("cg_debug_full.png"); - let status = std::process::Command::new("screencapture") - .arg("-x") - .arg(&tmp) - .status() - .map_err(|e| e.to_string())?; - if !status.success() { - return Err("screencapture failed".to_string()); - } - - let img = image::open(&tmp).map_err(|e| e.to_string())?; - let img_w = img.width(); - let img_h = img.height(); - // Clamp the crop rect to the captured image (window may extend off-screen). - let cw = w.min(img_w.saturating_sub(x)); - let ch = h.min(img_h.saturating_sub(y)); - if cw == 0 || ch == 0 { - return Err("window not visible on primary display".to_string()); - } - let cropped = img.crop_imm(x, y, cw, ch); - - let mut buf = Vec::new(); - let mut cursor = std::io::Cursor::new(&mut buf); - cropped - .write_to(&mut cursor, image::ImageFormat::Png) - .map_err(|e| e.to_string())?; - Ok(buf) -} - -#[cfg(debug_assertions)] -fn json_response(code: u16, body: &str) -> tiny_http::Response>> { - tiny_http::Response::from_data(body.as_bytes().to_vec()) - .with_status_code(code) - .with_header( - tiny_http::Header::from_bytes( - b"Content-Type".as_ref(), - b"application/json".as_ref(), - ) - .unwrap(), - ) - .with_header( - tiny_http::Header::from_bytes( - b"Access-Control-Allow-Origin".as_ref(), - b"*".as_ref(), - ) - .unwrap(), - ) -} diff --git a/gui/src-tauri/src/desktop_open.rs b/gui/src-tauri/src/desktop_open.rs deleted file mode 100644 index be7a5c46..00000000 --- a/gui/src-tauri/src/desktop_open.rs +++ /dev/null @@ -1,330 +0,0 @@ -use std::env; -use std::fs::metadata; -use std::path::Path; -use std::process::Command; - -use anyhow::Context; -use bstr::ByteSlice; - -const OPEN_TARGET_IDS: &[&str] = &[ - "cursor", - "zed", - "file-manager", - "terminal", - "ghostty", - "warp", - "xcode", - "android-studio", -]; - -pub fn detect_available_open_targets() -> Vec { - OPEN_TARGET_IDS - .iter() - .copied() - .filter(|target_id| is_open_target_available(target_id)) - .map(str::to_string) - .collect() -} - -// Copied "open in a specific IDE/app" path; not wired by the simple adapter -// yet (it uses open_path_default / open_path_for_edit). Kept for future parity. -#[allow(dead_code)] -pub fn open_path_in_target(target_id: &str, path: &Path) -> anyhow::Result<()> { - let mut command = build_open_command(target_id, path) - .with_context(|| format!("Unsupported open target: {target_id}"))?; - - let output = command.output().with_context(|| { - format!("Failed to launch {target_id}. Make sure the target app is installed.") - })?; - - if output.status.success() { - return Ok(()); - } - - let stderr = output.stderr.as_bstr().trim().to_str_lossy().into_owned(); - let stdout = output.stdout.as_bstr().trim().to_str_lossy().into_owned(); - if !stderr.is_empty() { - anyhow::bail!("{stderr}"); - } - if !stdout.is_empty() { - anyhow::bail!("{stdout}"); - } - - anyhow::bail!("Failed to launch {target_id}.") -} - -pub fn open_external_url(url: &str) -> anyhow::Result<()> { - let mut command = build_open_url_command(url); - let output = command - .output() - .with_context(|| "Failed to open the external URL.".to_string())?; - - if output.status.success() { - return Ok(()); - } - - let stderr = output.stderr.as_bstr().trim().to_str_lossy().into_owned(); - let stdout = output.stdout.as_bstr().trim().to_str_lossy().into_owned(); - if !stderr.is_empty() { - anyhow::bail!("{stderr}"); - } - if !stdout.is_empty() { - anyhow::bail!("{stdout}"); - } - - anyhow::bail!("Failed to open the external URL.") -} - -pub fn open_path_default(path: &Path) -> anyhow::Result<()> { - let mut command = build_open_path_command(path); - let output = command - .output() - .with_context(|| "Failed to open the file with the default app.".to_string())?; - - if output.status.success() { - return Ok(()); - } - - let stderr = output.stderr.as_bstr().trim().to_str_lossy().into_owned(); - let stdout = output.stdout.as_bstr().trim().to_str_lossy().into_owned(); - if !stderr.is_empty() { - anyhow::bail!("{stderr}"); - } - if !stdout.is_empty() { - anyhow::bail!("{stdout}"); - } - - anyhow::bail!("Failed to open the file with the default app.") -} - -fn is_open_target_available(target_id: &str) -> bool { - #[cfg(target_os = "macos")] - { - mac_app_name(target_id) - .map(is_mac_app_available) - .unwrap_or_else(|| target_id == "file-manager" && command_exists("open")) - } - - #[cfg(target_os = "windows")] - { - command_candidates(target_id) - .iter() - .copied() - .any(command_exists) - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - command_candidates(target_id) - .iter() - .copied() - .any(command_exists) - } -} - -#[allow(dead_code)] -fn build_open_command(target_id: &str, path: &Path) -> Option { - #[cfg(target_os = "macos")] - { - let mut command = Command::new("open"); - if let Some(app_name) = mac_app_name(target_id) { - command.arg("-a").arg(app_name); - } else if target_id != "file-manager" { - return None; - } - command.arg(path); - Some(command) - } - - #[cfg(target_os = "windows")] - { - let mut command = Command::new(resolve_command(target_id)?); - command.arg(path); - Some(command) - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - let mut command = Command::new(resolve_command(target_id)?); - command.arg(path); - Some(command) - } -} - -fn build_open_url_command(url: &str) -> Command { - #[cfg(target_os = "macos")] - { - let mut command = Command::new("open"); - command.arg(url); - command - } - - #[cfg(target_os = "windows")] - { - let mut command = Command::new("cmd"); - command.args(["/C", "start", "", url]); - command - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - let mut command = Command::new("xdg-open"); - command.arg(url); - command - } -} - -/// Opens a path in the default text editor (for editing config files that may -/// have no GUI file-type association, like `~/.zshrc`). -pub fn open_path_for_edit(path: &Path) -> anyhow::Result<()> { - let mut command = build_open_edit_command(path); - let output = command - .output() - .with_context(|| "Failed to open the file for editing.".to_string())?; - if output.status.success() { - return Ok(()); - } - let stderr = output.stderr.as_bstr().trim().to_str_lossy().into_owned(); - if !stderr.is_empty() { - anyhow::bail!("{stderr}"); - } - anyhow::bail!("Failed to open the file for editing.") -} - -fn build_open_edit_command(path: &Path) -> Command { - #[cfg(target_os = "macos")] - { - // `-t` opens with the default text editor rather than the (possibly - // unset) default app for an extensionless dotfile. - let mut command = Command::new("open"); - command.arg("-t").arg(path); - command - } - - #[cfg(target_os = "windows")] - { - let mut command = Command::new("notepad"); - command.arg(path); - command - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - let mut command = Command::new("xdg-open"); - command.arg(path); - command - } -} - -fn build_open_path_command(path: &Path) -> Command { - #[cfg(target_os = "macos")] - { - let mut command = Command::new("open"); - command.arg(path); - command - } - - #[cfg(target_os = "windows")] - { - let mut command = Command::new("cmd"); - command.arg("/C").arg("start").arg("").arg(path); - command - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - let mut command = Command::new("xdg-open"); - command.arg(path); - command - } -} - -#[cfg(target_os = "macos")] -fn mac_app_name(target_id: &str) -> Option<&'static str> { - match target_id { - "cursor" => Some("Cursor"), - "zed" => Some("Zed"), - "terminal" => Some("Terminal"), - "ghostty" => Some("Ghostty"), - "warp" => Some("Warp"), - "xcode" => Some("Xcode"), - "android-studio" => Some("Android Studio"), - "file-manager" => None, - _ => None, - } -} - -#[cfg(target_os = "windows")] -fn command_candidates(target_id: &str) -> &'static [&'static str] { - match target_id { - "cursor" => &["cursor"], - "zed" => &["zed"], - "file-manager" => &["explorer"], - "ghostty" => &["ghostty"], - "warp" => &["warp"], - "android-studio" => &["studio"], - _ => &[], - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn command_candidates(target_id: &str) -> &'static [&'static str] { - match target_id { - "cursor" => &["cursor"], - "zed" => &["zed"], - "file-manager" => &["xdg-open"], - "terminal" => &["x-terminal-emulator", "gnome-terminal"], - "ghostty" => &["ghostty"], - "warp" => &["warp"], - "android-studio" => &["studio"], - _ => &[], - } -} - -#[cfg(not(target_os = "macos"))] -fn resolve_command(target_id: &str) -> Option<&'static str> { - command_candidates(target_id) - .iter() - .copied() - .find(|command| command_exists(command)) - .or_else(|| command_candidates(target_id).first().copied()) -} - -#[cfg(target_os = "macos")] -fn is_mac_app_available(app_name: &str) -> bool { - Command::new("open") - .args(["-Ra", app_name]) - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -fn command_exists(command: &str) -> bool { - let path_value = env::var_os("PATH").unwrap_or_default(); - env::split_paths(&path_value).any(|directory| { - let candidate = directory.join(command); - is_executable_file(&candidate) - }) -} - -fn is_executable_file(path: &Path) -> bool { - let Ok(file_metadata) = metadata(path) else { - return false; - }; - - if !file_metadata.is_file() { - return false; - } - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - file_metadata.permissions().mode() & 0o111 != 0 - } - - #[cfg(windows)] - { - true - } -} diff --git a/gui/src-tauri/src/dto/activity.rs b/gui/src-tauri/src/dto/activity.rs deleted file mode 100644 index 6ea0b026..00000000 --- a/gui/src-tauri/src/dto/activity.rs +++ /dev/null @@ -1,263 +0,0 @@ -use roxmltree::{Document, Node}; -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -const TOOL_SUMMARY_LIMIT: usize = 220; -const TRUNCATED_OUTPUT_LABEL: &str = "… output truncated …"; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "FileOperation")] -pub enum FileOperationDto { - Create, - Overwrite, - Replace, - Remove, - Undo, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(rename = "ToolCallDetail")] -pub enum ToolCallDetailDto { - FileRead { - path: String, - #[serde(rename = "startLine")] - start_line: Option, - #[serde(rename = "endLine")] - end_line: Option, - }, - FileUpdate { - path: String, - operation: FileOperationDto, - }, - Shell { - command: String, - cwd: Option, - description: Option, - }, - Search { - pattern: String, - path: Option, - glob: Option, - #[serde(rename = "fileType")] - file_type: Option, - }, - CodebaseSearch { - queries: Vec, - }, - Fetch { - url: String, - }, - Followup { - question: String, - }, - Plan { - #[serde(rename = "planName")] - plan_name: String, - }, - Skill { - name: String, - }, - Task { - #[serde(rename = "agentId")] - agent_id: String, - label: String, - }, - TodoWrite { - count: usize, - }, - TodoRead, - Unknown { - name: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "OutputPreview")] -pub struct OutputPreviewDto { - pub content: String, - pub total_lines: usize, - pub head_display_lines: Option, - pub tail_display_lines: Option, - pub full_output_path: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(rename = "ToolResultDetail")] -#[allow(clippy::large_enum_variant)] -pub enum ToolResultDetailDto { - FileDiff { - path: String, - patch: String, - }, - ShellOutput { - command: String, - shell: String, - #[serde(rename = "exitCode")] - exit_code: Option, - description: Option, - stdout: Option, - stderr: Option, - }, - Text { - text: String, - }, -} - -pub fn map_tool_call_detail(_tool_call: &T) -> ToolCallDetailDto { - ToolCallDetailDto::Unknown { - name: "tool".to_string(), - } -} - -pub fn map_tool_result_detail(_result: &T) -> Option { - None -} - -pub fn summarize_tool_result(_result: &T) -> Option { - None -} - -pub(crate) fn normalize_tool_output_text(text: &str) -> Option { - let trimmed = text.trim(); - if trimmed.is_empty() { - return None; - } - - let document = match Document::parse(trimmed) { - Ok(document) => document, - Err(_) => return Some(trimmed.to_string()), - }; - let root = document.root_element(); - - match root.tag_name().name() { - "shell_output" => { - let preview = build_shell_output_text(root); - if preview.is_empty() { - Some(trimmed.to_string()) - } else { - Some(preview) - } - } - "tool_call_error" => { - let error = parse_tool_call_error_text(root); - if error.is_empty() { - Some(trimmed.to_string()) - } else { - Some(error) - } - } - _ => { - let plain_text = collect_node_text(root); - if plain_text.is_empty() { - Some(trimmed.to_string()) - } else { - Some(plain_text) - } - } - } -} - -fn parse_output_preview(node: Node<'_, '_>) -> Option { - let total_lines = parse_attr_usize(node, "total_lines").unwrap_or(0); - let full_output_path = node.attribute("full_output").map(str::to_string); - let head = node.children().find(|child| child.has_tag_name("head")); - let tail = node.children().find(|child| child.has_tag_name("tail")); - - let content = match (head, tail) { - (Some(head), Some(tail)) => { - let head_text = collect_preformatted_text(head); - let tail_text = collect_preformatted_text(tail); - if head_text.is_empty() && tail_text.is_empty() { - String::new() - } else { - format!("{head_text}\n\n{TRUNCATED_OUTPUT_LABEL}\n\n{tail_text}") - } - } - (Some(head), None) => collect_preformatted_text(head), - (None, Some(tail)) => collect_preformatted_text(tail), - (None, None) => collect_preformatted_text(node), - }; - - if content.is_empty() { - return None; - } - - Some(OutputPreviewDto { - content, - total_lines, - head_display_lines: head - .and_then(|child| child.attribute("display_lines")) - .map(str::to_string), - tail_display_lines: tail - .and_then(|child| child.attribute("display_lines")) - .map(str::to_string), - full_output_path, - }) -} - -fn build_shell_output_text(node: Node<'_, '_>) -> String { - let command = node.attribute("command").unwrap_or_default().trim(); - let stdout = node - .children() - .find(|child| child.has_tag_name("stdout")) - .and_then(parse_output_preview); - let stderr = node - .children() - .find(|child| child.has_tag_name("stderr")) - .and_then(parse_output_preview); - - let mut sections = Vec::new(); - if !command.is_empty() { - sections.push(format!("$ {command}")); - } - if let Some(stdout) = stdout.filter(|stdout| !stdout.content.is_empty()) { - sections.push(stdout.content); - } - if let Some(stderr) = stderr.filter(|stderr| !stderr.content.is_empty()) { - sections.push("[stderr]".to_string()); - sections.push(stderr.content); - } - - sections.join("\n\n").trim().to_string() -} - -fn parse_tool_call_error_text(node: Node<'_, '_>) -> String { - let cause = node - .children() - .find(|child| child.has_tag_name("cause")) - .map(collect_node_text) - .filter(|text| !text.is_empty()); - - cause.unwrap_or_else(|| collect_node_text(node)) -} - -fn collect_preformatted_text(node: Node<'_, '_>) -> String { - node.children() - .filter(|child| child.is_text()) - .filter_map(|child| child.text()) - .collect::() -} - -fn collect_node_text(node: Node<'_, '_>) -> String { - node.descendants() - .filter(|child| child.is_text()) - .filter_map(|child| child.text()) - .map(str::trim) - .filter(|text| !text.trim().is_empty()) - .collect::>() - .join("\n") - .trim() - .to_string() -} - -fn parse_attr_i32(node: Node<'_, '_>, name: &str) -> Option { - node.attribute(name)?.parse().ok() -} - -fn parse_attr_usize(node: Node<'_, '_>, name: &str) -> Option { - node.attribute(name)?.parse().ok() -} diff --git a/gui/src-tauri/src/dto/chat.rs b/gui/src-tauri/src/dto/chat.rs deleted file mode 100644 index 12f20684..00000000 --- a/gui/src-tauri/src/dto/chat.rs +++ /dev/null @@ -1,86 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::activity::{ToolCallDetailDto, ToolResultDetailDto}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "StatusCategory")] -pub enum StatusCategoryDto { - Action, - Info, - Debug, - Error, - Completion, - Warning, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(clippy::large_enum_variant)] -pub enum ChatEventKind { - Started, - AssistantMarkdown { - text: String, - }, - Reasoning { - text: String, - }, - Status { - title: String, - subtitle: Option, - category: StatusCategoryDto, - }, - StatusOutput { - text: String, - }, - ToolStart { - name: String, - #[serde(rename = "callId")] - call_id: Option, - detail: ToolCallDetailDto, - }, - ToolEnd { - name: String, - #[serde(rename = "callId")] - call_id: Option, - summary: Option, - #[serde(rename = "isError")] - is_error: bool, - detail: Option, - }, - Retry { - cause: String, - #[serde(rename = "durationMs")] - duration_ms: u64, - }, - Interrupt { - reason: String, - }, - Complete, - Error { - message: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct ChatEventDto { - pub request_id: String, - pub conversation_id: String, - pub event: ChatEventKind, -} - -impl ChatEventDto { - pub fn new( - request_id: impl Into, - conversation_id: impl Into, - event: ChatEventKind, - ) -> Self { - Self { - request_id: request_id.into(), - conversation_id: conversation_id.into(), - event, - } - } -} diff --git a/gui/src-tauri/src/dto/command.rs b/gui/src-tauri/src/dto/command.rs deleted file mode 100644 index e8a8b262..00000000 --- a/gui/src-tauri/src/dto/command.rs +++ /dev/null @@ -1,396 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::session::{ConversationSessionSummaryDto, SessionSnapshotDto}; - -/// Where a slash-command originates, mirroring how the REPL groups its -/// completion entries. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommandKind")] -pub enum CommandKindDto { - /// A built-in command from the `AppCommand` enum. - Builtin, - /// A per-agent switch command (`agent-`). - Agent, - /// A user-defined workflow command (`⚙`). - Workflow, -} - -/// How the GUI should execute or route a slash-command. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommandExecutionKind")] -pub enum CommandExecutionKindDto { - /// The command can run directly in the GUI backend. - Runnable, - /// The command opens or focuses a GUI modal/pane. - Modal, - /// The command is represented by a terminal/setup action in the GUI. - TerminalAssisted, - /// The command is known but not currently available in the GUI. - Unavailable, -} - -/// The high-level shape of a command result. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommandResultKind")] -pub enum CommandResultKindDto { - /// Plain text output. - Text, - /// A refreshed session snapshot. - Snapshot, - /// A path to a saved file. - SavedFile, - /// Agent list or active-agent details. - Agents, - /// Conversation list or operation result. - Conversations, - /// Conversation trajectory details. - Trajectory, - /// Workspace indexing status. - WorkspaceStatus, - /// Workspace indexing metadata. - WorkspaceInfo, - /// Workspace semantic search results. - WorkspaceSearch, - /// Workspace sync progress summary. - WorkspaceSync, - /// Workflow review/export payload. - WorkflowDraft, - /// MCP server/config payload. - Mcp, -} - -/// A single autocompletable graff command, surfaced to the GUI prompt. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommandDescriptor")] -pub struct CommandDescriptorDto { - /// Canonical command name (without the leading slash). - pub name: String, - /// Short, human-readable usage description. - pub usage: String, - /// Alternate names that also resolve to this command. - pub aliases: Vec, - /// Origin of the command. - pub kind: CommandKindDto, - /// Optional payload: agent id for `Agent`, prompt text for `Workflow`. - pub value: Option, - /// True for the agent-switch shorthands (`act`/`plan`/`sage`). - pub is_agent_switch: bool, - /// Execution route the GUI should use for this command. - pub execution_kind: CommandExecutionKindDto, - /// Whether the command requires an open workspace. - pub requires_workspace: bool, - /// Whether the command requires an active conversation. - pub requires_conversation: bool, - /// Optional argument hint shown in autocomplete and palettes. - pub argument_hint: Option, - /// Expected result shape when the command runs. - pub result_kind: CommandResultKindDto, -} - -/// Lightweight agent metadata for GUI status and picker surfaces. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "AgentSummary")] -pub struct AgentSummaryDto { - /// Stable agent identifier. - pub id: String, - /// Human-readable title. - pub title: String, - /// Human-readable description when configured. - pub description: Option, - /// Whether this agent is currently active. - pub is_active: bool, - /// Model configured for this agent when resolvable. - pub model_id: Option, -} - -/// Agent picker payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "AgentsPayload")] -pub struct AgentsPayloadDto { - /// Active agent id when one is selected. - pub active_agent_id: Option, - /// Selected provider id for the current session. - pub selected_provider_id: Option, - /// Selected model id for the current session. - pub selected_model_id: Option, - /// Selected reasoning effort for the current model. - pub selected_reasoning_effort: Option, - /// Available agents. - pub agents: Vec, -} - -/// Conversation operation payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ConversationsPayload")] -pub struct ConversationsPayloadDto { - /// Workspace path the conversations belong to. - pub workspace_path: Option, - /// Serialized conversation summaries compatible with existing snapshots. - pub conversations: Vec, -} - -/// A single indexed-workspace file status. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceFileStatus")] -pub struct WorkspaceFileStatusDto { - /// Relative path. - pub path: String, - /// Sync status label. - pub status: String, -} - -/// Workspace indexing status payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceStatusPayload")] -pub struct WorkspaceStatusPayloadDto { - /// Workspace path queried. - pub workspace_path: String, - /// File status entries. - pub files: Vec, -} - -/// Workspace indexing metadata payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceInfoPayload")] -pub struct WorkspaceInfoPayloadDto { - /// Workspace path queried. - pub workspace_path: String, - /// Remote workspace id if indexed. - pub workspace_id: Option, - /// Indexed working directory if known. - pub working_dir: Option, - /// Indexed node count. - pub node_count: Option, - /// Indexed relation count. - pub relation_count: Option, - /// Last update timestamp as RFC3339 text. - pub last_updated: Option, - /// Created timestamp as RFC3339 text. - pub created_at: Option, -} - -/// Input for workspace semantic search. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceQueryInput")] -pub struct WorkspaceQueryInput { - /// Workspace path to query. - pub workspace_path: String, - /// Semantic query text. - pub query: String, - /// Search use case for reranking. - pub use_case: Option, - /// Maximum number of results. - pub limit: Option, - /// Optional path prefix filter. - pub starts_with: Option, - /// Optional suffix filters. - pub ends_with: Option>, -} - -/// A normalized semantic search result. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceSearchResult")] -pub struct WorkspaceSearchResultDto { - /// Node id returned by the index service. - pub node_id: String, - /// Result kind. - pub kind: String, - /// File path when applicable. - pub path: Option, - /// Starting line when applicable. - pub start_line: Option, - /// Ending line when applicable. - pub end_line: Option, - /// Text preview. - pub preview: String, - /// Relevance score when returned. - pub relevance: Option, - /// Distance score when returned. - pub distance: Option, -} - -/// Workspace semantic search payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceSearchPayload")] -pub struct WorkspaceSearchPayloadDto { - /// Workspace path queried. - pub workspace_path: String, - /// Original query. - pub query: String, - /// Results in backend rank order. - pub results: Vec, -} - -/// Workspace sync summary payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceSyncPayload")] -pub struct WorkspaceSyncPayloadDto { - /// Workspace path synced. - pub workspace_path: String, - /// Human-readable progress events. - pub events: Vec, -} - -/// Input for building a GUI workflow draft. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkflowDraftInput")] -pub struct WorkflowDraftInput { - /// Goal the workflow should accomplish. - pub goal: String, -} - -/// A single workflow review node. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkflowNode")] -pub struct WorkflowNodeDto { - /// Stable node name. - pub name: String, - /// Worker agent id. - pub worker: String, - /// Task assigned to the worker. - pub task: String, - /// Node dependencies. - pub dependencies: Vec, - /// Prior node outputs this node may inspect. - pub access: Vec, - /// Expected artifact. - pub artifact: String, - /// Stop condition. - pub stop_condition: String, -} - -/// Workflow draft payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkflowDraftPayload")] -pub struct WorkflowDraftPayloadDto { - /// Goal the workflow should accomplish. - pub goal: String, - /// Compact topology summary. - pub summary: String, - /// Review nodes. - pub nodes: Vec, - /// Exportable YAML-like representation. - pub export_text: String, - /// Prompt submitted when the workflow is approved. - pub approved_prompt: String, - /// Initial trace lines. - pub trace: Vec, -} - -/// A single MCP server in settings. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "McpServerSummary")] -pub struct McpServerSummaryDto { - /// MCP server name. - pub name: String, - /// Server transport type. - pub server_type: String, - /// Command or URL. - pub target: String, - /// Whether the server is disabled. - pub is_disabled: bool, - /// Number of tools currently loaded. - pub tool_count: usize, - /// Tool names currently loaded. - pub tools: Vec, - /// Initialization error when available. - pub error: Option, - /// OAuth auth status for HTTP servers when known. - pub auth_status: Option, -} - -/// MCP settings payload. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "McpSettingsPayload")] -pub struct McpSettingsPayloadDto { - /// Configured servers. - pub servers: Vec, -} - -/// Input for importing MCP configuration JSON. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "McpImportInput")] -pub struct McpImportInput { - /// Configuration scope: `user` or `local`. - pub scope: String, - /// JSON text containing `mcpServers`. - pub json: String, -} - -/// Input for an MCP server action. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "McpServerActionInput")] -pub struct McpServerActionInput { - /// MCP server name. - pub name: String, - /// Configuration scope: `user` or `local` when the action edits config. - pub scope: Option, -} - -/// Structured command payloads used by GUI-first surfaces. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] -#[serde(tag = "kind", rename_all = "camelCase")] -#[ts(rename = "CommandPayload")] -pub enum CommandPayloadDto { - /// Agent picker/status payload. - Agents(AgentsPayloadDto), - /// Conversation operation payload. - Conversations(ConversationsPayloadDto), - /// Workspace status payload. - WorkspaceStatus(WorkspaceStatusPayloadDto), - /// Workspace info payload. - WorkspaceInfo(WorkspaceInfoPayloadDto), - /// Workspace search payload. - WorkspaceSearch(WorkspaceSearchPayloadDto), - /// Workspace sync payload. - WorkspaceSync(WorkspaceSyncPayloadDto), - /// Workflow draft payload. - WorkflowDraft(WorkflowDraftPayloadDto), - /// MCP settings payload. - Mcp(McpSettingsPayloadDto), -} - -/// The outcome of executing a command via `run_command`. Exactly one of the -/// payload fields is populated, telling the GUI how to surface the result. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommandRunResult")] -pub struct CommandRunResultDto { - /// Heading for the result dialog. - pub title: String, - /// Informational text to display in a dialog (monospace). `None` when the - /// command mutated state instead of producing output. - pub body: Option, - /// A refreshed session snapshot to apply when the command changed state - /// (e.g. compaction, rename). - pub snapshot: Option, - /// A filesystem path the command wrote to (e.g. `/dump`), surfaced to the - /// user so they can locate the output. - pub saved_path: Option, - /// High-level result shape. - pub result_kind: CommandResultKindDto, - /// Structured payload for first-class GUI surfaces. - pub payload: Option, -} diff --git a/gui/src-tauri/src/dto/followup.rs b/gui/src-tauri/src/dto/followup.rs deleted file mode 100644 index 0535cf9c..00000000 --- a/gui/src-tauri/src/dto/followup.rs +++ /dev/null @@ -1,42 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "FollowupKind")] -pub enum FollowupKind { - Text, - Single, - Multi, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "FollowupOption")] -pub struct FollowupOptionDto { - pub id: String, - pub label: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "FollowupRequest")] -pub struct FollowupRequestDto { - pub followup_id: String, - pub workspace_path: String, - pub conversation_id: String, - pub request_id: String, - pub kind: FollowupKind, - pub question: String, - pub options: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "FollowupResponse")] -pub struct FollowupResponseDto { - pub followup_id: String, - pub cancelled: bool, - pub text: Option, - pub selected_option_ids: Option>, -} diff --git a/gui/src-tauri/src/dto/mod.rs b/gui/src-tauri/src/dto/mod.rs deleted file mode 100644 index 1427b85d..00000000 --- a/gui/src-tauri/src/dto/mod.rs +++ /dev/null @@ -1,42 +0,0 @@ -// Copied GUI scaffolding: the tool-output parsing helpers in `activity` are -// superseded by the simple adapter's own mapping; kept for future parity. -#[allow(dead_code)] -mod activity; -mod chat; -mod command; -mod followup; -mod runtime; -mod session; -mod terminal; - -pub use activity::{FileOperationDto, OutputPreviewDto, ToolCallDetailDto, ToolResultDetailDto}; -pub use chat::{ChatEventDto, ChatEventKind, StatusCategoryDto}; -pub use command::{ - AgentSummaryDto, AgentsPayloadDto, CommandDescriptorDto, CommandExecutionKindDto, - CommandKindDto, CommandPayloadDto, CommandResultKindDto, CommandRunResultDto, - ConversationsPayloadDto, McpImportInput, McpServerActionInput, McpServerSummaryDto, - McpSettingsPayloadDto, WorkflowDraftInput, WorkflowDraftPayloadDto, WorkflowNodeDto, - WorkspaceFileStatusDto, WorkspaceInfoPayloadDto, WorkspaceQueryInput, - WorkspaceSearchPayloadDto, WorkspaceSearchResultDto, WorkspaceStatusPayloadDto, - WorkspaceSyncPayloadDto, -}; -pub use followup::{FollowupKind, FollowupOptionDto, FollowupRequestDto, FollowupResponseDto}; -pub use runtime::{ - ChatHandoffTargetDto, CheckoutGitBranchInput, CloneRepositoryInput, CommitGitChangesInput, - CompleteProviderAuthInput, CreateGitBranchInput, CreateSavedWorkspaceInput, - GitWorkspaceKindDto, HandoffChatInput, ProviderEnvOverrideDto, PromptModelOptionDto, - PromptSettingsDto, ProviderAuthMethodDto, ProviderAuthMethodKindDto, ProviderAuthSessionDto, - ProviderAuthSessionKindDto, ProviderOAuthCallbackDto, ProviderSummaryDto, ProviderUrlParamDto, - ProviderUrlParamValueDto, QuickStartProjectInput, QuickStartVisibility, RemoveProviderInput, - RuntimeStatusDto, SaveConversationLayoutInput, SendPromptInput, StartProviderAuthInput, - UpdatePromptSettingsInput, UpdateSavedWorkspaceLayoutInput, -}; -pub use session::{ - ChatBindingDto, ConversationSessionSummaryDto, ConversationViewSnapshotDto, - SavedWorkspaceDetailDto, SavedWorkspaceSummaryDto, SessionMessageDto, SessionSnapshotDto, - SessionTodoDto, SessionTodoStatusDto, WorkspaceKindDto, WorkspaceSessionDto, -}; -pub use terminal::{ - TerminalCloseInput, TerminalErrorEventDto, TerminalExitEventDto, TerminalOpenInput, - TerminalOutputEventDto, TerminalResizeInput, TerminalSessionDto, TerminalWriteInput, -}; diff --git a/gui/src-tauri/src/dto/runtime.rs b/gui/src-tauri/src/dto/runtime.rs deleted file mode 100644 index ca9b9541..00000000 --- a/gui/src-tauri/src/dto/runtime.rs +++ /dev/null @@ -1,527 +0,0 @@ -use std::collections::HashSet; -use std::path::Path; -use std::process::Command; - -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::session::ChatBindingDto; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "RuntimeStatus")] -pub struct RuntimeStatusDto { - pub workspace_path: Option, - pub workspace_name: Option, - pub git_repo_name: Option, - pub git_branch_name: Option, - pub git_branches: Vec, - pub git_workspace_kind: Option, - pub git_main_workspace_path: Option, - pub available_open_targets: Vec, - pub configured: bool, - pub configuration_error: Option, -} - -impl RuntimeStatusDto { - pub fn new( - workspace_path: Option<&Path>, - configured: bool, - configuration_error: Option, - ) -> Self { - let git_details = workspace_path.and_then(read_git_workspace_details); - - Self { - workspace_path: workspace_path.map(|path| path.to_string_lossy().into_owned()), - workspace_name: workspace_path - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().into_owned()), - git_repo_name: git_details - .as_ref() - .and_then(|details| details.repo_name.clone()), - git_branch_name: git_details - .as_ref() - .and_then(|details| details.branch_name.clone()), - git_branches: git_details - .as_ref() - .map(|details| details.branch_names.clone()) - .unwrap_or_default(), - git_workspace_kind: git_details - .as_ref() - .and_then(|details| details.workspace_kind.clone()), - git_main_workspace_path: git_details - .as_ref() - .and_then(|details| details.main_workspace_path.clone()), - available_open_targets: crate::desktop_open::detect_available_open_targets(), - configured, - configuration_error, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "GitWorkspaceKind")] -pub enum GitWorkspaceKindDto { - Local, - Worktree, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct GitWorkspaceDetails { - pub(crate) repo_name: Option, - pub(crate) branch_name: Option, - pub(crate) branch_names: Vec, - pub(crate) workspace_kind: Option, - pub(crate) main_workspace_path: Option, - pub(crate) workspace_root_path: Option, - pub(crate) head_commit: Option, -} - -pub(crate) fn read_git_workspace_details(workspace_path: &Path) -> Option { - let workspace_root_path = - run_git_raw_command(workspace_path, &["rev-parse", "--show-toplevel"]); - let head_commit = run_git_raw_command(workspace_path, &["rev-parse", "HEAD"]); - let repo_name = run_git_command(workspace_path, &["remote", "get-url", "origin"]) - .and_then(|remote_url| parse_git_remote_name(&remote_url)); - let branch_name = run_git_command(workspace_path, &["rev-parse", "--abbrev-ref", "HEAD"]) - .or_else(|| { - run_git_command( - workspace_path, - &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], - ) - }); - let local_branch_names = run_git_lines_command( - workspace_path, - &["for-each-ref", "--format=%(refname:short)", "refs/heads"], - ) - .unwrap_or_default(); - let local_branch_name_set = local_branch_names.iter().cloned().collect::>(); - let remote_branch_names = run_git_lines_command( - workspace_path, - &["for-each-ref", "--format=%(refname:short)", "refs/remotes"], - ) - .unwrap_or_default() - .into_iter() - .filter(|candidate| candidate.contains('/') && !candidate.ends_with("/HEAD")) - .filter(|candidate| { - let local_equivalent = candidate - .split_once('/') - .map(|(_, remainder)| remainder) - .unwrap_or(candidate.as_str()); - !local_branch_name_set.contains(local_equivalent) - }) - .collect::>(); - let mut seen_branch_names = HashSet::new(); - let branch_names = local_branch_names - .into_iter() - .chain(remote_branch_names) - .filter(|candidate| seen_branch_names.insert(candidate.clone())) - .collect::>(); - let main_workspace_path = - resolve_main_workspace_path(workspace_path, workspace_root_path.as_deref()); - let workspace_kind = match ( - workspace_root_path.as_deref(), - main_workspace_path.as_deref(), - ) { - (Some(workspace_root_path), Some(main_workspace_path)) - if workspace_root_path == main_workspace_path => - { - Some(GitWorkspaceKindDto::Local) - } - (Some(_), Some(_)) => Some(GitWorkspaceKindDto::Worktree), - _ => None, - }; - - if repo_name.is_none() - && branch_name.is_none() - && branch_names.is_empty() - && workspace_kind.is_none() - && main_workspace_path.is_none() - && workspace_root_path.is_none() - && head_commit.is_none() - { - return None; - } - - Some(GitWorkspaceDetails { - repo_name, - branch_name, - branch_names, - workspace_kind, - main_workspace_path, - workspace_root_path, - head_commit, - }) -} - -fn run_git_command(workspace_path: &Path, args: &[&str]) -> Option { - let mut values = run_git_lines_command(workspace_path, args)?; - values.retain(|value| value != "HEAD"); - values.into_iter().next() -} - -fn run_git_raw_command(workspace_path: &Path, args: &[&str]) -> Option { - run_git_lines_command(workspace_path, args)? - .into_iter() - .next() -} - -fn run_git_lines_command(workspace_path: &Path, args: &[&str]) -> Option> { - let output = Command::new("git") - .args(args) - .current_dir(workspace_path) - .output() - .ok()?; - if !output.status.success() { - return None; - } - - let value = String::from_utf8(output.stdout).ok()?; - let values = value - .lines() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .collect::>(); - if values.is_empty() { - return None; - } - - Some(values) -} - -fn resolve_main_workspace_path( - workspace_path: &Path, - workspace_root_path: Option<&str>, -) -> Option { - let raw_common_dir = run_git_raw_command( - workspace_path, - &["rev-parse", "--path-format=absolute", "--git-common-dir"], - ) - .or_else(|| run_git_raw_command(workspace_path, &["rev-parse", "--git-common-dir"]))?; - let common_dir_path = resolve_git_path(workspace_path, workspace_root_path, &raw_common_dir)?; - let main_workspace_path = common_dir_path.parent()?.to_path_buf(); - canonicalize_or_normalize_path(main_workspace_path) -} - -fn resolve_git_path( - workspace_path: &Path, - workspace_root_path: Option<&str>, - raw_path: &str, -) -> Option { - let candidate = std::path::PathBuf::from(raw_path); - if candidate.is_absolute() { - return Some(candidate); - } - - let base_path = workspace_root_path - .map(std::path::PathBuf::from) - .unwrap_or_else(|| workspace_path.to_path_buf()); - Some(base_path.join(candidate)) -} - -fn canonicalize_or_normalize_path(path: std::path::PathBuf) -> Option { - let resolved = path.canonicalize().unwrap_or(path); - Some(resolved.to_string_lossy().into_owned()) -} - -fn parse_git_remote_name(remote_url: &str) -> Option { - let normalized = remote_url - .trim() - .trim_end_matches('/') - .trim_end_matches(".git"); - let path = if let Some((_, remainder)) = normalized.split_once("://") { - remainder.split_once('/').map(|(_, path)| path)? - } else if normalized.contains('@') && normalized.contains(':') { - normalized.rsplit_once(':').map(|(_, path)| path)? - } else { - normalized - }; - - let segments = path - .split('/') - .filter(|segment| !segment.is_empty()) - .collect::>(); - - if segments.len() < 2 { - return None; - } - - Some(format!( - "{}/{}", - segments[segments.len() - 2], - segments[segments.len() - 1] - )) -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "PromptModelOption")] -pub struct PromptModelOptionDto { - pub provider_id: String, - pub provider_name: String, - pub model_id: String, - pub model_name: Option, - pub context_length: Option, - pub supports_reasoning: bool, - pub reasoning_efforts: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "PromptSettings")] -pub struct PromptSettingsDto { - pub available_models: Vec, - pub selected_provider_id: Option, - pub selected_model_id: Option, - pub selected_reasoning_effort: Option, - pub fast_enabled: bool, - /// Whether toggling fast actually changes harness behavior for the selected - /// provider. The harness only applies /fast on the codex provider (the - /// `responses` kind): it emits `service_tier:"priority"` there and reports - /// `applies:false` everywhere else. True only when the selected provider is - /// codex; the GUI greys out the toggle when this is false. - pub fast_applies: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "UpdatePromptSettingsInput")] -pub struct UpdatePromptSettingsInput { - pub workspace_path: Option, - pub provider_id: String, - pub model_id: String, - pub reasoning_effort: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "ProviderAuthMethodKind")] -pub enum ProviderAuthMethodKindDto { - ApiKey, - OAuthDevice, - OAuthCode, - GoogleAdc, - AwsProfile, - CodexDevice, - CodegraffDevice, - /// Kimi Code device-code OAuth (`graff login kimi`). Kimi also accepts an - /// API key, so this is offered as a second auth method alongside `ApiKey`. - KimiDevice, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderAuthMethod")] -pub struct ProviderAuthMethodDto { - pub kind: ProviderAuthMethodKindDto, - pub label: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderSummary")] -pub struct ProviderSummaryDto { - pub id: String, - pub name: String, - pub configured: bool, - pub auth_methods: Vec, - /// Set when the provider is configured via an environment variable, so the - /// UI can point at where it's defined (the app can't unset an env var). - pub env_override: Option, -} - -/// Where a provider's `_API_KEY` environment variable comes from. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderEnvOverride")] -pub struct ProviderEnvOverrideDto { - /// The environment variable name (e.g. `OPENAI_API_KEY`). - pub env_key: String, - /// Shell config file that defines it, if found (e.g. `~/.zshrc`). - pub file_path: Option, - /// 1-based line number of the definition, if found. - pub line: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderUrlParam")] -pub struct ProviderUrlParamDto { - pub name: String, - pub value: Option, - pub options: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderUrlParamValue")] -pub struct ProviderUrlParamValueDto { - pub name: String, - pub value: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "ProviderAuthSessionKind")] -pub enum ProviderAuthSessionKindDto { - ApiKey, - DeviceCode, - CliLogin, - OAuthCode, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderAuthSession")] -pub struct ProviderAuthSessionDto { - pub kind: ProviderAuthSessionKindDto, - pub auth_session_id: String, - pub requires_api_key: bool, - pub api_key_hint: Option, - pub url_parameters: Vec, - pub verification_uri: Option, - pub verification_uri_complete: Option, - pub user_code: Option, - pub expires_in_seconds: Option, - pub authorization_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ProviderOAuthCallback")] -pub struct ProviderOAuthCallbackDto { - pub auth_session_id: String, - pub provider_id: String, - pub authorization_code: Option, - pub error_message: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "StartProviderAuthInput")] -pub struct StartProviderAuthInput { - pub workspace_path: Option, - pub provider_id: String, - pub auth_method: ProviderAuthMethodKindDto, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CompleteProviderAuthInput")] -pub struct CompleteProviderAuthInput { - pub auth_session_id: String, - pub api_key: Option, - pub authorization_code: Option, - pub url_parameters: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "RemoveProviderInput")] -pub struct RemoveProviderInput { - pub workspace_path: Option, - pub provider_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendPromptInput { - pub workspace_path: String, - pub prompt: String, - pub conversation_id: Option, - pub agent_id: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "ChatHandoffTarget")] -pub enum ChatHandoffTargetDto { - Local, - Worktree, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "HandoffChatInput")] -pub struct HandoffChatInput { - pub source_workspace_path: String, - pub conversation_id: Option, - pub target: ChatHandoffTargetDto, - pub branch_name: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CloneRepositoryInput")] -pub struct CloneRepositoryInput { - pub repository_url: String, - pub parent_directory: String, - pub directory_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "QuickStartVisibility")] -pub enum QuickStartVisibility { - Public, - Private, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "QuickStartProjectInput")] -pub struct QuickStartProjectInput { - pub project_name: String, - pub parent_directory: String, - pub visibility: QuickStartVisibility, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CheckoutGitBranchInput")] -pub struct CheckoutGitBranchInput { - pub workspace_path: String, - pub branch_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CreateGitBranchInput")] -pub struct CreateGitBranchInput { - pub workspace_path: String, - pub branch_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CommitGitChangesInput")] -pub struct CommitGitChangesInput { - pub workspace_path: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "CreateSavedWorkspaceInput")] -pub struct CreateSavedWorkspaceInput { - pub chats: Vec, - pub layout_json: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "UpdateSavedWorkspaceLayoutInput")] -pub struct UpdateSavedWorkspaceLayoutInput { - pub workspace_id: String, - pub layout_json: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "SaveConversationLayoutInput")] -pub struct SaveConversationLayoutInput { - pub conversation_id: String, - pub layout_json: String, -} diff --git a/gui/src-tauri/src/dto/session.rs b/gui/src-tauri/src/dto/session.rs deleted file mode 100644 index a4bcc19d..00000000 --- a/gui/src-tauri/src/dto/session.rs +++ /dev/null @@ -1,199 +0,0 @@ -use std::collections::HashMap; - -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -use super::activity::{ToolCallDetailDto, ToolResultDetailDto}; -use super::chat::StatusCategoryDto; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(rename = "SessionMessage")] -#[allow(clippy::large_enum_variant)] -pub enum SessionMessageDto { - User { - id: String, - #[serde(rename = "requestId")] - request_id: String, - text: String, - }, - ContextCompacted { - id: String, - #[serde(rename = "requestId")] - request_id: String, - text: String, - }, - Assistant { - id: String, - #[serde(rename = "requestId")] - request_id: String, - text: String, - }, - Reasoning { - id: String, - #[serde(rename = "requestId")] - request_id: String, - text: String, - }, - Status { - id: String, - #[serde(rename = "requestId")] - request_id: String, - title: String, - subtitle: Option, - category: StatusCategoryDto, - }, - StatusOutput { - id: String, - #[serde(rename = "requestId")] - request_id: String, - text: String, - }, - ToolStart { - id: String, - #[serde(rename = "requestId")] - request_id: String, - name: String, - #[serde(rename = "callId")] - call_id: Option, - detail: ToolCallDetailDto, - }, - ToolEnd { - id: String, - #[serde(rename = "requestId")] - request_id: String, - name: String, - #[serde(rename = "callId")] - call_id: Option, - summary: Option, - #[serde(rename = "isError")] - is_error: bool, - detail: Option, - }, - Error { - id: String, - #[serde(rename = "requestId")] - request_id: String, - message: String, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "SessionTodoStatus")] -pub enum SessionTodoStatusDto { - Pending, - InProgress, - Completed, - Cancelled, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "SessionTodo")] -pub struct SessionTodoDto { - pub id: String, - pub content: String, - pub status: SessionTodoStatusDto, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ConversationSessionSummary")] -pub struct ConversationSessionSummaryDto { - pub conversation_id: String, - pub title: String, - pub updated_at: Option, - pub is_draft: bool, - pub is_running: bool, - pub has_pending_followup: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ChatBinding")] -pub struct ChatBindingDto { - pub workspace_path: String, - pub conversation_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "ConversationViewSnapshot")] -pub struct ConversationViewSnapshotDto { - pub workspace_path: String, - pub conversation_id: String, - pub messages: Vec, - pub active_request_ids: Vec, - pub request_agent_ids: HashMap, - pub todos: Vec, - /// Steering objective for this chat, set via `/goal`. Surfaced in the UI as - /// the goal chip above the composer. Omitted from the payload when unset. - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub goal: Option, - pub followup: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename = "WorkspaceKind")] -pub enum WorkspaceKindDto { - Project, - ManagedChat, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "WorkspaceSession")] -pub struct WorkspaceSessionDto { - pub kind: WorkspaceKindDto, - pub workspace_path: String, - pub workspace_name: String, - pub configured: bool, - pub configuration_error: Option, - pub selected_conversation_id: Option, - pub conversations: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "SavedWorkspaceSummary")] -pub struct SavedWorkspaceSummaryDto { - pub id: String, - pub name: String, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "SavedWorkspaceDetail")] -pub struct SavedWorkspaceDetailDto { - pub id: String, - pub name: String, - pub layout_json: String, - pub updated_at: i64, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "SessionSnapshot")] -pub struct SessionSnapshotDto { - pub active_workspace_path: Option, - pub active_conversation_id: Option, - pub visible_messages: Vec, - pub visible_active_request_ids: Vec, - pub visible_request_agent_ids: HashMap, - pub visible_todos: Vec, - /// Steering objective of the active chat, set via `/goal`. Mirrors - /// `ConversationViewSnapshotDto::goal` for the visible conversation. Omitted - /// from the payload when no goal is set. - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub visible_goal: Option, - pub visible_followup: Option, - pub conversation_views: Vec, - pub ui_error: Option, - pub workspaces: Vec, - pub saved_workspaces: Vec, -} diff --git a/gui/src-tauri/src/dto/terminal.rs b/gui/src-tauri/src/dto/terminal.rs deleted file mode 100644 index 98c93216..00000000 --- a/gui/src-tauri/src/dto/terminal.rs +++ /dev/null @@ -1,72 +0,0 @@ -use serde::{Deserialize, Serialize}; -use ts_rs::TS; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalOpenInput")] -pub struct TerminalOpenInput { - pub terminal_id: String, - pub workspace_path: String, - pub cols: u16, - pub rows: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalWriteInput")] -pub struct TerminalWriteInput { - pub terminal_id: String, - pub data: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalResizeInput")] -pub struct TerminalResizeInput { - pub terminal_id: String, - pub cols: u16, - pub rows: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalCloseInput")] -pub struct TerminalCloseInput { - pub terminal_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalSession")] -pub struct TerminalSessionDto { - pub terminal_id: String, - pub workspace_path: String, - pub shell: String, - pub cols: u16, - pub rows: u16, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalOutputEvent")] -pub struct TerminalOutputEventDto { - pub terminal_id: String, - pub data: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalExitEvent")] -pub struct TerminalExitEventDto { - pub terminal_id: String, - pub exit_code: Option, - pub signal: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename = "TerminalErrorEvent")] -pub struct TerminalErrorEventDto { - pub terminal_id: String, - pub message: String, -} diff --git a/gui/src-tauri/src/forge_config_home.rs b/gui/src-tauri/src/forge_config_home.rs deleted file mode 100644 index 940097eb..00000000 --- a/gui/src-tauri/src/forge_config_home.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -const APP_FORGE_DIR_NAME: &str = "forge"; -const IMPORT_MARKER_FILE: &str = ".codegraff-gui-imported-global-forge"; -const IMPORT_FILES: &[&str] = &[ - ".forge.toml", - ".config.json", - ".credentials.json", - "provider.json", - ".forge.db", - ".forge_history", - "AGENTS.md", -]; -const IMPORT_DIRS: &[&str] = &["commands", "skills"]; - -pub(crate) fn initialize_app_forge_config( - app_dir: &Path, - home_dir: Option<&Path>, -) -> anyhow::Result { - let app_forge_dir = app_dir.join(APP_FORGE_DIR_NAME); - fs::create_dir_all(&app_forge_dir)?; - - import_global_forge_config_once(&app_forge_dir, home_dir)?; - - // Forge crates resolve config, provider credentials, and provider.json from - // FORGE_CONFIG, so set it before any runtime config read happens. - unsafe { - std::env::set_var("FORGE_CONFIG", &app_forge_dir); - } - - Ok(app_forge_dir) -} - -fn import_global_forge_config_once( - app_forge_dir: &Path, - home_dir: Option<&Path>, -) -> anyhow::Result<()> { - let marker_path = app_forge_dir.join(IMPORT_MARKER_FILE); - if marker_path.exists() { - return Ok(()); - } - - let marker_contents = if is_app_forge_dir_uninitialized(app_forge_dir)? { - if let Some(source) = find_global_forge_dir(app_forge_dir, home_dir) { - match copy_forge_config(&source, app_forge_dir) { - Ok(()) => format!("imported_from={}\n", source.display()), - Err(error) => { - log::warn!( - "Failed to import global Forge config from {}: {error:#}", - source.display() - ); - format!( - "imported_from={}\nimport_error={error:#}\n", - source.display() - ) - } - } - } else { - "imported_from=\n".to_string() - } - } else { - "imported_from=\nexisting_app_config=true\n".to_string() - }; - fs::write(marker_path, marker_contents)?; - - Ok(()) -} - -fn is_app_forge_dir_uninitialized(app_forge_dir: &Path) -> anyhow::Result { - for entry in fs::read_dir(app_forge_dir)? { - let entry = entry?; - if entry.file_name() != IMPORT_MARKER_FILE { - return Ok(false); - } - } - - Ok(true) -} - -fn find_global_forge_dir(app_forge_dir: &Path, home_dir: Option<&Path>) -> Option { - let mut candidates = Vec::new(); - - if let Some(path) = std::env::var_os("FORGE_CONFIG").map(PathBuf::from) { - candidates.push(path); - } - - if let Some(home_dir) = home_dir { - candidates.push(home_dir.join("forge")); - candidates.push(home_dir.join(".forge")); - } - - candidates - .into_iter() - .find(|path| path.is_dir() && !same_directory(path, app_forge_dir)) -} - -fn same_directory(left: &Path, right: &Path) -> bool { - match (left.canonicalize(), right.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => left == right, - } -} - -fn copy_forge_config(source_dir: &Path, app_forge_dir: &Path) -> anyhow::Result<()> { - for file_name in IMPORT_FILES { - copy_file_if_present(&source_dir.join(file_name), &app_forge_dir.join(file_name))?; - } - - for dir_name in IMPORT_DIRS { - copy_dir_if_present(&source_dir.join(dir_name), &app_forge_dir.join(dir_name))?; - } - - Ok(()) -} - -fn copy_file_if_present(source: &Path, destination: &Path) -> anyhow::Result<()> { - if !source.is_file() || destination.exists() { - return Ok(()); - } - - if let Some(parent) = destination.parent() { - fs::create_dir_all(parent)?; - } - fs::copy(source, destination)?; - - Ok(()) -} - -fn copy_dir_if_present(source: &Path, destination: &Path) -> anyhow::Result<()> { - if !source.is_dir() || destination.exists() { - return Ok(()); - } - - copy_dir_recursive(source, destination) -} - -fn copy_dir_recursive(source: &Path, destination: &Path) -> anyhow::Result<()> { - fs::create_dir_all(destination)?; - - for entry in fs::read_dir(source)? { - let entry = entry?; - let file_type = entry.file_type()?; - let source_path = entry.path(); - let destination_path = destination.join(entry.file_name()); - - if file_type.is_dir() { - copy_dir_recursive(&source_path, &destination_path)?; - } else if file_type.is_file() { - fs::copy(&source_path, &destination_path)?; - } - } - - Ok(()) -} diff --git a/gui/src-tauri/src/lib.rs b/gui/src-tauri/src/lib.rs deleted file mode 100644 index 9e315557..00000000 --- a/gui/src-tauri/src/lib.rs +++ /dev/null @@ -1,211 +0,0 @@ -mod commands; -mod cli_install; -mod debug_bridge; -mod desktop_open; -pub mod dto; -mod forge_config_home; -mod runtime; -mod terminal; - -mod bridge { - pub mod emitter; - // Copied OAuth/tonic follow-up bridge; unused by the simple adapter - // (graff has no follow-up tool). Retained for future parity. - #[allow(dead_code)] - pub mod followup; -} - -mod persistence { - // Some registration/layout helpers are copied but not yet exercised by the - // simple adapter. Retained for future parity. - #[allow(dead_code)] - pub mod project_store; -} - -use std::sync::Arc; - -use bridge::emitter::TauriEventEmitter; -use commands::{ - archive_conversation, archive_workspace, build_workflow_draft, checkout_git_branch, - clone_repository, commit_git_changes, compact_conversation, complete_provider_auth, - create_git_branch, create_managed_chat, create_saved_workspace, delete_saved_workspace, - ensure_conversation_view, export_workflow_draft, get_conversation_layout, get_prompt_settings, - get_runtime_status, get_saved_workspace, get_session_snapshot, handoff_chat, import_mcp_config, - list_commands, list_mcp_servers, list_providers, login_mcp_server, logout_mcp_server, - open_external_url, open_in_target, open_path_default, open_path_for_edit, open_path_in_target, - open_workspace, - pick_directory, pick_workspace, push_git_branch, quick_start_project, read_workspace_file, - reload_mcp_servers, - remove_mcp_server, remove_provider, rename_saved_workspace, rename_workspace, respond_followup, - run_slash_command, save_conversation_layout, select_conversation, send_prompt, - image_thumbnail, save_pasted_image, set_active_agent, set_effort, set_fast, start_new_chat, start_provider_auth, stop_prompt, terminal_close, - terminal_open, terminal_resize, terminal_write, update_prompt_settings, - update_saved_workspace_layout, workspace_query, workspace_sync, -}; -use persistence::project_store::ProjectStore; -use runtime::DesktopState; -use tauri::{Emitter, Manager}; - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - install_rustls_crypto_provider(); - - // single-instance must be registered first. A second `codegraff ` - // invocation lands here: open the path in the running window and focus it. - let builder = tauri::Builder::default() - .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { - if let Some(path) = workspace_path_from_args(argv) { - let _ = app.emit("open-workspace-path", path); - } - if let Some(window) = app.get_webview_window("main") { - let _ = window.set_focus(); - } - })) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .plugin(tauri_plugin_process::init()); - #[cfg(debug_assertions)] - let builder = builder.plugin( - tauri_plugin_log::Builder::default() - .level(log::LevelFilter::Info) - .build(), - ); - - builder - .setup(|app| { - let emitter = Arc::new(TauriEventEmitter::new(app.handle().clone())); - let app_dir = app - .path() - .app_data_dir() - .map_err(|error| anyhow::anyhow!(error.to_string()))?; - let home_dir = app.path().home_dir().ok(); - forge_config_home::initialize_app_forge_config(&app_dir, home_dir.as_deref())?; - let projects = Arc::new(ProjectStore::new( - app_dir.join("projects.db"), - app_dir.join("managed-chats"), - )?); - app.manage(DesktopState::new(emitter, projects)); - // Cold start: stash a `codegraff ` arg so the UI opens it once ready. - app.manage(PendingOpen(std::sync::Mutex::new(workspace_path_from_args( - std::env::args(), - )))); - // First run: install the `codegraff` launcher command on PATH (macOS). - cli_install::ensure_cli_symlink(app); - #[cfg(debug_assertions)] - { - let bridge = debug_bridge::DebugBridge::new(app.handle().clone()); - app.manage(bridge.clone()); - bridge.start(); - } - Ok(()) - }) - .on_window_event(|window, event| { - if matches!(event, tauri::WindowEvent::Destroyed) { - let manager = window.state::().manager.clone(); - tauri::async_runtime::spawn(async move { - manager.cancel_pending_followups().await; - }); - } - }) - .invoke_handler(tauri::generate_handler![ - pick_workspace, - pick_directory, - open_workspace, - get_runtime_status, - get_session_snapshot, - get_prompt_settings, - list_commands, - list_providers, - select_conversation, - ensure_conversation_view, - start_new_chat, - create_managed_chat, - handoff_chat, - send_prompt, - compact_conversation, - run_slash_command, - workspace_sync, - workspace_query, - build_workflow_draft, - export_workflow_draft, - set_active_agent, - set_effort, - set_fast, - save_pasted_image, - image_thumbnail, - list_mcp_servers, - import_mcp_config, - remove_mcp_server, - reload_mcp_servers, - login_mcp_server, - logout_mcp_server, - stop_prompt, - update_prompt_settings, - start_provider_auth, - complete_provider_auth, - remove_provider, - respond_followup, - archive_conversation, - archive_workspace, - rename_workspace, - clone_repository, - quick_start_project, - checkout_git_branch, - create_git_branch, - commit_git_changes, - push_git_branch, - open_in_target, - open_path_in_target, - open_external_url, - open_path_default, - open_path_for_edit, - read_workspace_file, - save_conversation_layout, - get_conversation_layout, - create_saved_workspace, - update_saved_workspace_layout, - get_saved_workspace, - rename_saved_workspace, - delete_saved_workspace, - terminal_open, - terminal_write, - terminal_resize, - terminal_close, - drain_pending_open, - debug_bridge::cg_debug_eval_result - ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); -} -/// Holds a workspace path passed on the command line before the UI was ready -/// (cold start). The frontend drains it via `drain_pending_open` once mounted. -struct PendingOpen(std::sync::Mutex>); - -/// Resolve a `codegraff ` argument to an absolute workspace path. Skips -/// the executable (argv[0]) and any leading flags, then takes the first -/// argument that exists on disk and canonicalizes it. -fn workspace_path_from_args>(args: I) -> Option { - for arg in args.into_iter().skip(1) { - if arg.starts_with('-') { - continue; - } - if let Ok(abs) = std::fs::canonicalize(&arg) { - return Some(abs.to_string_lossy().into_owned()); - } - } - None -} - -/// Hand the UI any path passed to `codegraff ` at cold start (consumed once). -#[tauri::command] -fn drain_pending_open(pending: tauri::State<'_, PendingOpen>) -> Option { - pending.0.lock().ok().and_then(|mut slot| slot.take()) -} - -fn install_rustls_crypto_provider() { - if rustls::crypto::CryptoProvider::get_default().is_some() { - return; - } - - let _ = rustls::crypto::ring::default_provider().install_default(); -} diff --git a/gui/src-tauri/src/main.rs b/gui/src-tauri/src/main.rs deleted file mode 100644 index 3ec6c7fc..00000000 --- a/gui/src-tauri/src/main.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Prevents additional console window on Windows in release, DO NOT REMOVE!! -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -fn main() { - codegraff_gui_lib::run(); -} diff --git a/gui/src-tauri/src/persistence/project_store.rs b/gui/src-tauri/src/persistence/project_store.rs deleted file mode 100644 index 7e202b98..00000000 --- a/gui/src-tauri/src/persistence/project_store.rs +++ /dev/null @@ -1,686 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Mutex; - -use anyhow::Context; -use diesel::connection::SimpleConnection; -use diesel::prelude::*; -use diesel::sql_types::{BigInt, Nullable, Text}; -use diesel::{QueryableByName, RunQueryDsl, SqliteConnection, sql_query}; -use uuid::Uuid; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RegisteredWorkspaceKind { - Project, - ManagedChat, -} - -#[derive(Debug, Clone)] -pub struct RegisteredWorkspace { - pub kind: RegisteredWorkspaceKind, - pub path: PathBuf, - pub display_name: Option, -} - -#[derive(Debug, Clone)] -pub struct RegisteredWorkspaceMetadata { - pub kind: RegisteredWorkspaceKind, - pub display_name: Option, -} - -#[derive(Debug)] -pub struct ProjectStore { - db_path: PathBuf, - managed_chats_root: PathBuf, - connection_lock: Mutex<()>, -} - -#[derive(Debug, Clone, QueryableByName)] -pub struct SavedWorkspaceSummaryRecord { - #[diesel(sql_type = Text)] - pub id: String, - #[diesel(sql_type = Text)] - pub name: String, - #[diesel(sql_type = BigInt)] - pub updated_at: i64, -} - -#[derive(Debug, Clone, QueryableByName)] -pub struct SavedWorkspaceRecord { - #[diesel(sql_type = Text)] - pub id: String, - #[diesel(sql_type = Text)] - pub name: String, - #[diesel(sql_type = Text)] - pub layout_json: String, - #[diesel(sql_type = BigInt)] - pub updated_at: i64, -} - -impl ProjectStore { - pub fn new(db_path: PathBuf, managed_chats_root: PathBuf) -> anyhow::Result { - let store = Self { - db_path, - managed_chats_root, - connection_lock: Mutex::new(()), - }; - store.init()?; - Ok(store) - } - - pub fn add_project(&self, path: &Path) -> anyhow::Result<()> { - let canonical = canonicalize_project_path(path)?; - self.with_connection(|connection| { - sql_query( - " - INSERT INTO opened_projects (path, last_opened_at) - VALUES (?1, unixepoch()) - ON CONFLICT(path) DO NOTHING - ", - ) - .bind::(canonical) - .execute(connection)?; - Ok(()) - }) - } - - pub fn create_managed_chat_workspace(&self) -> anyhow::Result { - fs::create_dir_all(&self.managed_chats_root).with_context(|| { - format!( - "Failed to create managed chat workspace directory at {}", - self.managed_chats_root.display() - ) - })?; - - let managed_chat_path = self - .managed_chats_root - .join(format!("chat_{}", Uuid::new_v4().simple())); - fs::create_dir_all(&managed_chat_path).with_context(|| { - format!( - "Failed to create managed chat workspace at {}", - managed_chat_path.display() - ) - })?; - - let canonical = canonicalize_project_path(&managed_chat_path)?; - self.with_connection(|connection| { - sql_query( - " - INSERT INTO managed_chat_workspaces (path, created_at, last_opened_at) - VALUES (?1, unixepoch(), unixepoch()) - ", - ) - .bind::(&canonical) - .execute(connection)?; - Ok(()) - })?; - - Ok(PathBuf::from(canonical)) - } - - pub fn list_workspaces(&self) -> anyhow::Result> { - self.with_connection(|connection| { - let rows: Vec = sql_query( - " - SELECT path, kind, display_name - FROM ( - SELECT path, 'project' AS kind, display_name, last_opened_at - FROM opened_projects - UNION ALL - SELECT path, 'managed_chat' AS kind, display_name, last_opened_at - FROM managed_chat_workspaces - ) - ORDER BY last_opened_at DESC, path ASC - ", - ) - .load(connection)?; - - Ok(rows - .into_iter() - .map(|row| RegisteredWorkspace { - kind: parse_workspace_kind(&row.kind), - display_name: normalize_display_name(row.display_name.as_deref()), - path: PathBuf::from(row.path), - }) - .filter(|workspace| workspace.path.exists()) - .collect()) - }) - } - - pub fn get_workspace_registration( - &self, - path: &Path, - ) -> anyhow::Result> { - let canonical = canonicalize_project_path(path)?; - self.with_connection(|connection| { - let rows: Vec = sql_query( - " - SELECT kind, display_name - FROM ( - SELECT 'project' AS kind, display_name - FROM opened_projects - WHERE path = ?1 - UNION ALL - SELECT 'managed_chat' AS kind, display_name - FROM managed_chat_workspaces - WHERE path = ?1 - ) - LIMIT 1 - ", - ) - .bind::(canonical) - .load(connection)?; - - Ok(rows - .into_iter() - .next() - .map(|row| RegisteredWorkspaceMetadata { - kind: parse_workspace_kind(&row.kind), - display_name: normalize_display_name(row.display_name.as_deref()), - })) - }) - } - - pub fn get_workspace_kind( - &self, - path: &Path, - ) -> anyhow::Result> { - Ok(self - .get_workspace_registration(path)? - .map(|registration| registration.kind)) - } - - pub fn set_workspace_display_name( - &self, - path: &Path, - display_name: Option<&str>, - ) -> anyhow::Result<()> { - let canonical = canonicalize_project_path(path)?; - let display_name = normalize_display_name(display_name); - self.with_connection(|connection| { - let mut updated_rows = 0usize; - updated_rows += sql_query( - " - UPDATE opened_projects - SET display_name = ?2 - WHERE path = ?1 - ", - ) - .bind::(&canonical) - .bind::, _>(display_name.clone()) - .execute(connection)?; - - updated_rows += sql_query( - " - UPDATE managed_chat_workspaces - SET display_name = ?2 - WHERE path = ?1 - ", - ) - .bind::(&canonical) - .bind::, _>(display_name) - .execute(connection)?; - - if updated_rows == 0 { - anyhow::bail!("Workspace is not registered."); - } - - Ok(()) - }) - } - - pub fn touch_managed_chat_workspace(&self, path: &Path) -> anyhow::Result<()> { - let canonical = canonicalize_project_path(path)?; - self.with_connection(|connection| { - sql_query( - " - UPDATE managed_chat_workspaces - SET last_opened_at = unixepoch() - WHERE path = ?1 - ", - ) - .bind::(canonical) - .execute(connection)?; - Ok(()) - }) - } - - pub fn archive_workspace(&self, path: &Path) -> anyhow::Result<()> { - let canonical = canonicalize_workspace_removal_path(path); - self.with_connection(|connection| { - sql_query( - " - DELETE FROM opened_projects - WHERE path = ?1 - ", - ) - .bind::(&canonical) - .execute(connection)?; - - sql_query( - " - DELETE FROM managed_chat_workspaces - WHERE path = ?1 - ", - ) - .bind::(&canonical) - .execute(connection)?; - Ok(()) - }) - } - - pub fn delete_conversation_layout(&self, conversation_id: &str) -> anyhow::Result<()> { - self.with_connection(|connection| { - sql_query( - " - DELETE FROM conversation_layouts - WHERE conversation_id = ?1 - ", - ) - .bind::(conversation_id) - .execute(connection)?; - Ok(()) - }) - } - - pub fn save_conversation_layout( - &self, - conversation_id: &str, - layout_json: &str, - ) -> anyhow::Result<()> { - self.with_connection(|connection| { - sql_query( - " - INSERT INTO conversation_layouts (conversation_id, layout_json, updated_at) - VALUES (?1, ?2, unixepoch()) - ON CONFLICT(conversation_id) DO UPDATE SET - layout_json = excluded.layout_json, - updated_at = excluded.updated_at - ", - ) - .bind::(conversation_id) - .bind::(layout_json) - .execute(connection)?; - Ok(()) - }) - } - - pub fn get_conversation_layout(&self, conversation_id: &str) -> anyhow::Result> { - self.with_connection(|connection| { - let rows: Vec = sql_query( - " - SELECT layout_json - FROM conversation_layouts - WHERE conversation_id = ?1 - LIMIT 1 - ", - ) - .bind::(conversation_id) - .load(connection)?; - - Ok(rows.into_iter().next().map(|row| row.layout_json)) - }) - } - - pub fn create_saved_workspace( - &self, - workspace_id: &str, - name: &str, - layout_json: &str, - ) -> anyhow::Result { - self.with_connection(|connection| { - connection.transaction(|connection| { - sql_query( - " - INSERT INTO saved_workspaces (id, name, created_at, updated_at) - VALUES (?1, ?2, unixepoch(), unixepoch()) - ", - ) - .bind::(workspace_id) - .bind::(name) - .execute(connection)?; - - sql_query( - " - INSERT INTO saved_workspace_layouts (workspace_id, layout_json, updated_at) - VALUES (?1, ?2, unixepoch()) - ", - ) - .bind::(workspace_id) - .bind::(layout_json) - .execute(connection)?; - - load_saved_workspace_record(connection, workspace_id) - }) - }) - } - - pub fn update_saved_workspace_layout( - &self, - workspace_id: &str, - layout_json: &str, - ) -> anyhow::Result { - self.with_connection(|connection| { - connection.transaction(|connection| { - sql_query( - " - UPDATE saved_workspaces - SET updated_at = unixepoch() - WHERE id = ?1 - ", - ) - .bind::(workspace_id) - .execute(connection)?; - - sql_query( - " - INSERT INTO saved_workspace_layouts (workspace_id, layout_json, updated_at) - VALUES (?1, ?2, unixepoch()) - ON CONFLICT(workspace_id) DO UPDATE SET - layout_json = excluded.layout_json, - updated_at = excluded.updated_at - ", - ) - .bind::(workspace_id) - .bind::(layout_json) - .execute(connection)?; - - load_saved_workspace_record(connection, workspace_id) - }) - }) - } - - pub fn list_saved_workspaces(&self) -> anyhow::Result> { - self.with_connection(|connection| { - let rows = sql_query( - " - SELECT id, name, updated_at - FROM saved_workspaces - ORDER BY created_at DESC, id ASC - ", - ) - .load(connection)?; - - Ok(rows) - }) - } - - pub fn get_saved_workspace( - &self, - workspace_id: &str, - ) -> anyhow::Result> { - self.with_connection(|connection| { - load_optional_saved_workspace_record(connection, workspace_id) - }) - } - - pub fn rename_saved_workspace( - &self, - workspace_id: &str, - name: &str, - ) -> anyhow::Result { - let trimmed_name = name.trim(); - if trimmed_name.is_empty() { - anyhow::bail!("Workspace name cannot be empty."); - } - - self.with_connection(|connection| { - connection.transaction(|connection| { - sql_query( - " - UPDATE saved_workspaces - SET name = ?2, - updated_at = unixepoch() - WHERE id = ?1 - ", - ) - .bind::(workspace_id) - .bind::(trimmed_name) - .execute(connection)?; - - load_saved_workspace_record(connection, workspace_id) - }) - }) - } - - pub fn delete_saved_workspace(&self, workspace_id: &str) -> anyhow::Result<()> { - self.with_connection(|connection| { - sql_query( - " - DELETE FROM saved_workspaces - WHERE id = ?1 - ", - ) - .bind::(workspace_id) - .execute(connection)?; - Ok(()) - }) - } - - fn init(&self) -> anyhow::Result<()> { - if let Some(parent) = self.db_path.parent() { - fs::create_dir_all(parent).with_context(|| { - format!( - "Failed to create project registry directory at {}", - parent.display() - ) - })?; - } - - self.with_connection(|connection| { - connection.batch_execute( - " - CREATE TABLE IF NOT EXISTS opened_projects ( - path TEXT PRIMARY KEY, - last_opened_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS managed_chat_workspaces ( - path TEXT PRIMARY KEY, - created_at INTEGER NOT NULL, - last_opened_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS conversation_layouts ( - conversation_id TEXT PRIMARY KEY, - layout_json TEXT NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS saved_workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS saved_workspace_layouts ( - workspace_id TEXT PRIMARY KEY, - layout_json TEXT NOT NULL, - updated_at INTEGER NOT NULL, - FOREIGN KEY(workspace_id) REFERENCES saved_workspaces(id) ON DELETE CASCADE - ); - ", - )?; - ensure_column_exists(connection, "opened_projects", "display_name", "TEXT")?; - ensure_column_exists( - connection, - "managed_chat_workspaces", - "display_name", - "TEXT", - )?; - Ok(()) - }) - } - - fn open(&self) -> anyhow::Result { - let mut connection = SqliteConnection::establish(self.db_path.to_string_lossy().as_ref()) - .with_context(|| { - format!( - "Failed to open project registry database at {}", - self.db_path.display() - ) - })?; - connection.batch_execute( - " - PRAGMA journal_mode = WAL; - PRAGMA busy_timeout = 5000; - PRAGMA foreign_keys = ON; - ", - )?; - Ok(connection) - } - - fn with_connection( - &self, - operation: impl FnOnce(&mut SqliteConnection) -> anyhow::Result, - ) -> anyhow::Result { - let _connection_lock = self - .connection_lock - .lock() - .expect("project store mutex poisoned"); - let mut connection = self.open()?; - operation(&mut connection) - } -} - -#[derive(QueryableByName)] -struct RegisteredWorkspaceRow { - #[diesel(sql_type = Text)] - path: String, - #[diesel(sql_type = Text)] - kind: String, - #[diesel(sql_type = Nullable)] - display_name: Option, -} - -#[derive(QueryableByName)] -struct RegisteredWorkspaceRegistrationRow { - #[diesel(sql_type = Text)] - kind: String, - #[diesel(sql_type = Nullable)] - display_name: Option, -} - -#[derive(QueryableByName)] -struct ConversationLayoutRow { - #[diesel(sql_type = Text)] - layout_json: String, -} - -#[derive(QueryableByName)] -struct TableColumnRow { - #[diesel(sql_type = Text)] - name: String, -} - -fn canonicalize_project_path(path: &Path) -> anyhow::Result { - Ok(path - .canonicalize() - .with_context(|| format!("Failed to canonicalize project path {}", path.display()))? - .to_string_lossy() - .into_owned()) -} - -fn canonicalize_workspace_removal_path(path: &Path) -> String { - canonicalize_project_path(path).unwrap_or_else(|_| path.to_string_lossy().into_owned()) -} - -fn parse_workspace_kind(value: &str) -> RegisteredWorkspaceKind { - match value { - "managed_chat" => RegisteredWorkspaceKind::ManagedChat, - _ => RegisteredWorkspaceKind::Project, - } -} - -fn normalize_display_name(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) -} - -fn ensure_column_exists( - connection: &mut SqliteConnection, - table: &str, - column: &str, - definition: &str, -) -> anyhow::Result<()> { - let rows: Vec = - sql_query(format!("SELECT name FROM pragma_table_info('{table}')")).load(connection)?; - if rows.iter().any(|existing| existing.name == column) { - return Ok(()); - } - - connection.batch_execute(&format!( - "ALTER TABLE {table} ADD COLUMN {column} {definition};" - ))?; - Ok(()) -} - -fn load_saved_workspace_record( - connection: &mut SqliteConnection, - workspace_id: &str, -) -> anyhow::Result { - load_optional_saved_workspace_record(connection, workspace_id)? - .with_context(|| format!("Saved workspace not found: {workspace_id}")) -} - -fn load_optional_saved_workspace_record( - connection: &mut SqliteConnection, - workspace_id: &str, -) -> anyhow::Result> { - let rows: Vec = sql_query( - " - SELECT sw.id, sw.name, swl.layout_json, sw.updated_at - FROM saved_workspaces sw - INNER JOIN saved_workspace_layouts swl - ON swl.workspace_id = sw.id - WHERE sw.id = ?1 - LIMIT 1 - ", - ) - .bind::(workspace_id) - .load(connection)?; - - Ok(rows.into_iter().next()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn test_store() -> anyhow::Result<(ProjectStore, PathBuf)> { - let root = std::env::temp_dir().join(format!( - "codegraff-project-store-test-{}", - Uuid::new_v4().simple() - )); - fs::create_dir_all(&root)?; - let store = ProjectStore::new(root.join("projects.sqlite"), root.join("managed-chats"))?; - Ok((store, root)) - } - - #[test] - fn archive_workspace_removes_registry_entry_for_missing_folder() -> anyhow::Result<()> { - let (store, root) = test_store()?; - let project_path = root.join("deleted-project"); - fs::create_dir_all(&project_path)?; - let canonical_project_path = project_path.canonicalize()?; - - store.add_project(&canonical_project_path)?; - fs::remove_dir_all(&canonical_project_path)?; - - store.archive_workspace(&canonical_project_path)?; - fs::create_dir_all(&canonical_project_path)?; - - let registered_paths: Vec = store - .list_workspaces()? - .into_iter() - .map(|workspace| workspace.path) - .collect(); - assert!(!registered_paths.contains(&canonical_project_path)); - - fs::remove_dir_all(root)?; - Ok(()) - } -} diff --git a/gui/src-tauri/src/runtime/mod.rs b/gui/src-tauri/src/runtime/mod.rs deleted file mode 100644 index 9660b037..00000000 --- a/gui/src-tauri/src/runtime/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod simple; - -pub use simple::DesktopState; -pub(crate) use simple::{canonicalize_workspace_path, format_error_chain}; diff --git a/gui/src-tauri/src/runtime/simple.rs b/gui/src-tauri/src/runtime/simple.rs deleted file mode 100644 index bc0c7852..00000000 --- a/gui/src-tauri/src/runtime/simple.rs +++ /dev/null @@ -1,4716 +0,0 @@ -use crate::{ - bridge::emitter::UiEventEmitter, - desktop_open, - dto::*, - persistence::project_store::{ProjectStore, RegisteredWorkspaceKind}, - terminal::TerminalManager, -}; -use anyhow::{Context, Result}; -use base64::Engine; -#[cfg(target_os = "macos")] -use std::os::unix::fs::PermissionsExt; -use std::{ - collections::{HashMap, HashSet, VecDeque}, - path::{Path, PathBuf}, - process::Command, - sync::Arc, -}; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::sync::Mutex; -use uuid::Uuid; - -#[derive(Clone)] -struct QueuedPrompt { - agent_id: Option, - engine_prompt: String, - request_id: String, -} - -#[derive(Clone, Default)] -struct ConversationState { - workspace_path: String, - conversation_id: String, - title: String, - messages: Vec, - active_request_ids: Vec, - /// Maps a request id to the agent label ("muse"/"forge") it was sent under, - /// so the GUI's plan-decision card can key off the planning-mode request. - request_agent_ids: HashMap, - queued_prompts: VecDeque, - active_agent_id: Option, - plan_mode: bool, - /// Live task list, replaced wholesale on every `todo_write` tool call - /// (harness semantics). Surfaced to the GUI's task-progress dock. - todos: Vec, - goal: Option, - /// Persistent ultracode (multi-agent workflow) mode, scoped to this chat. - ultracode_enabled: bool, - updated_at: i64, -} - -#[derive(Default)] -struct RuntimeState { - active_workspace_path: Option, - active_conversation_id: Option, - workspaces: Vec, - conversations: HashMap, - selected_by_workspace: HashMap, - active_agent_id: Option, - /// UI model selection, applied as `--model` when a session is spawned. - selected_provider: Option, - selected_model: Option, - /// Thinking controls (set via the GUI), applied on session spawn and - /// forwarded live to running sessions. None effort = the binary default. - selected_effort: Option, - fast_enabled: bool, - /// Per-user model usage counters used to rank the model picker. - model_usage: ModelUsageByProvider, - /// Last successful `graff --schema` model list. Refreshed when the prompt - /// settings endpoint is read so the GUI picks up newly shipped models - /// without an app restart. - model_catalog: Option>, - /// Live `ask_user` prompts awaiting a GUI answer, keyed by conversation. - /// Surfaced as the conversation's `followup` so the FollowupComposer renders. - pending_followups: HashMap, -} - -/// One model the `graff` CLI advertises in `--schema`. -#[derive(Clone)] -struct ModelOption { - provider: String, - provider_name: Option, - name: String, - context: Option, - is_default: bool, -} - -/// A persistent `graff --json` child bound to one conversation. Keeping the -/// process alive across turns preserves conversation context (and the model's -/// KV cache). -struct GraffSession { - child: tokio::process::Child, - /// Shared so an `ask_user` answer can be written to stdin (via - /// `respond_followup`) while a turn is mid-stream — the turn only holds the - /// reader, the stdin handle stays reachable here. - stdin: Arc>, - /// Taken out for the duration of an in-flight turn so only one request - /// streams per session at a time (the protocol's contract). - reader: Option>>, -} - -struct GraffSessionIo { - stdin: Arc>, - reader: tokio::io::Lines>, -} - -/// Coordinates the copied desktop GUI with the Zig-native codegraff binary. -#[derive(Clone)] -pub struct RuntimeManager { - emitter: Arc, - projects: Arc, - state: Arc>, - sessions: Arc>>, -} - -impl RuntimeManager { - /// Creates a runtime manager backed by the copied GUI project registry. - pub fn new(emitter: Arc, projects: Arc) -> Self { - let persisted_prompt = load_persisted_prompt_settings(); - Self { - emitter, - projects, - state: Arc::new(Mutex::new(RuntimeState { - active_agent_id: Some("forge".into()), - conversations: load_persisted_conversations(), - selected_provider: persisted_prompt.selected_provider, - selected_model: persisted_prompt.selected_model, - selected_effort: persisted_prompt.selected_effort, - fast_enabled: persisted_prompt.fast_enabled, - model_usage: persisted_prompt.model_usage, - ..RuntimeState::default() - })), - sessions: Arc::new(Mutex::new(HashMap::new())), - } - } - - /// Cancels pending follow-up prompts. - pub async fn cancel_pending_followups(&self) { - self.state.lock().await.pending_followups.clear(); - } - - /// Returns the current GUI session snapshot. - pub async fn get_session_snapshot(&self) -> Result { - self.snapshot().await - } - - /// Opens a workspace and records it in the GUI registry. - pub async fn open_workspace(&self, path: PathBuf) -> Result { - let workspace_path = canonicalize_workspace_path(path)?; - self.projects.add_project(Path::new(&workspace_path))?; - let mut state = self.state.lock().await; - set_active_workspace(&mut state, &workspace_path); - drop(state); - self.snapshot().await - } - - /// Returns basic runtime status for a workspace. - pub async fn get_runtime_status( - &self, - workspace_path: Option, - ) -> Result { - let active = self.state.lock().await.active_workspace_path.clone(); - Ok(RuntimeStatusDto::new( - workspace_path.or(active).as_deref().map(Path::new), - true, - None, - )) - } - - /// Returns the model picker, populated from `graff --schema`. - pub async fn get_prompt_settings(&self, _: Option) -> Result { - let catalog = self.refresh_model_catalog().await; - let configured_provider_ids = configured_provider_ids().await; - let (selected_provider, selected_model, selected_effort, fast_enabled, model_usage) = { - let state = self.state.lock().await; - ( - state.selected_provider.clone(), - state.selected_model.clone(), - state.selected_effort.clone(), - state.fast_enabled, - state.model_usage.clone(), - ) - }; - - Ok(build_prompt_settings_from_catalog( - &catalog, - &configured_provider_ids, - selected_provider.as_deref(), - selected_model.as_deref(), - selected_effort, - fast_enabled, - &model_usage, - )) - } - - async fn selected_model_pair(&self) -> Option<(String, String)> { - let catalog = self.ensure_model_catalog().await; - let configured_provider_ids = configured_provider_ids().await; - let (selected_provider, selected_model) = { - let state = self.state.lock().await; - ( - state.selected_provider.clone(), - state.selected_model.clone(), - ) - }; - selected_prompt_model_pair( - &catalog, - &configured_provider_ids, - selected_provider.as_deref(), - selected_model.as_deref(), - ) - } - - async fn selected_model_arg(&self) -> Option { - self.selected_model_pair() - .await - .map(|(provider, model)| prompt_model_arg(&provider, &model)) - } - - /// Generates a short chat title with the active model (a one-shot graff run) - /// and updates the conversation. Best-effort and fire-and-forget: failures - /// just leave the prompt-derived title in place. - async fn generate_and_set_title( - &self, - conversation_id: String, - prompt: String, - model_arg: Option, - ) { - let title_prompt = format!( - "Output a concise 2 to 4 word topic label in Title Case for the user's \ - first chat message. Describe the topic literally — do NOT interpret, \ - correct, rephrase, or expand the message. Examples: \"hey there\" -> \ - Greeting; \"go through the repo\" -> Repo Walkthrough; \"fix the login \ - bug\" -> Login Bug Fix. Reply with ONLY the label, nothing else.\n\nMessage: {prompt}" - ); - let mut command = tokio::process::Command::new(codegraff_binary()); - command.arg("-p").arg(&title_prompt).arg("--yolo"); - if let Some(model) = &model_arg { - command.arg("--model").arg(model); - } - let Ok(output) = command.output().await else { - return; - }; - if !output.status.success() { - return; - } - let title = clean_title(&String::from_utf8_lossy(&output.stdout)); - if title.is_empty() { - return; - } - { - let mut state = self.state.lock().await; - if let Some(conversation) = state.conversations.get_mut(&conversation_id) { - conversation.title = title; - } - } - self.persist_conversations().await; - let _ = self.emit().await; - } - - /// Writes the current model/effort/fast selection to disk so it persists - /// across app restarts and seeds new chats. - async fn persist_prompt_settings(&self) { - let settings = { - let state = self.state.lock().await; - PersistedPromptSettings { - selected_provider: state.selected_provider.clone(), - selected_model: state.selected_model.clone(), - selected_effort: state.selected_effort.clone(), - fast_enabled: state.fast_enabled, - model_usage: state.model_usage.clone(), - } - }; - save_prompt_settings(&settings); - } - - async fn record_model_usage(&self, provider_id: &str, model_id: &str) { - { - let mut state = self.state.lock().await; - increment_model_usage(&mut state.model_usage, provider_id, model_id, now_millis()); - } - self.persist_prompt_settings().await; - } - - /// Persists the model selection and applies it to the live session when possible. - pub async fn update_prompt_settings( - &self, - input: UpdatePromptSettingsInput, - ) -> Result { - let catalog = self.refresh_model_catalog().await; - let configured_provider_ids = configured_provider_ids().await; - let available_catalog = filtered_catalog_models(&catalog, &configured_provider_ids); - let selected_model = available_catalog - .iter() - .copied() - .find(|model| model.provider == input.provider_id && model.name == input.model_id) - .with_context(|| { - format!( - "{} is not available for provider {}. Choose a configured provider/model from the picker.", - input.model_id, input.provider_id - ) - })?; - let live_conversation_id = { - let mut state = self.state.lock().await; - state.selected_provider = Some(selected_model.provider.clone()); - state.selected_model = Some(selected_model.name.clone()); - if let Some(effort) = &input.reasoning_effort { - state.selected_effort = Some(effort.clone()); - } - state.active_conversation_id.clone() - }; - if let Some(conversation_id) = live_conversation_id { - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ - "type": "set_model", - // Send BOTH model and provider so the harness routes to the - // exact provider the user picked in the menu (gpt-5.5 from - // Codex vs the Codegraff gateway). A bare model can't be - // pinned to a provider, so an explicit Codex pick was dropped - // and models that exist under two providers (the two gpt-5.5 - // entries) failed to switch at all — the engine emits an - // `error` event, send_control surfaces it, and the whole - // update aborts (then the UI swallows it). - "model": selected_model.name.as_str(), - "provider": selected_model.provider.as_str(), - }), - ) - .await?; - if let Some(effort) = &input.reasoning_effort { - self.send_control( - &conversation_id, - serde_json::json!({ "type": "set_effort", "level": effort }), - ) - .await?; - } - } - } - self.persist_prompt_settings().await; - self.get_prompt_settings(input.workspace_path).await - } - - /// Returns the cached model catalog, fetching `graff --schema` on first use. - async fn ensure_model_catalog(&self) -> Vec { - { - let state = self.state.lock().await; - if let Some(catalog) = &state.model_catalog { - return catalog.clone(); - } - } - self.refresh_model_catalog().await - } - - /// Re-reads `graff --schema` so prompt-settings reflects the current binary's - /// provider/model list. If the schema read fails after a successful earlier - /// read, keep showing the last known catalog rather than blanking the picker. - async fn refresh_model_catalog(&self) -> Vec { - let catalog = codegraff_schema_models().await; - let mut state = self.state.lock().await; - if catalog.is_empty() { - return state.model_catalog.clone().unwrap_or_default(); - } - state.model_catalog = Some(catalog.clone()); - catalog - } - - /// Lists providers supported by the Zig-native codegraff binary. - pub async fn list_providers(&self, _: Option) -> Result> { - list_codegraff_providers().await - } - - /// Starts provider auth using the target repo's native credential flows. - pub async fn start_provider_auth( - &self, - input: StartProviderAuthInput, - ) -> Result { - match input.auth_method { - ProviderAuthMethodKindDto::ApiKey => Ok(ProviderAuthSessionDto { - kind: ProviderAuthSessionKindDto::ApiKey, - auth_session_id: auth_session_id(&input.provider_id), - requires_api_key: true, - api_key_hint: provider_env_key(&input.provider_id) - .map(|env_key| format!("Paste an API key. Equivalent env var: {env_key}.")) - .or_else(|| Some("Paste an API key for this provider.".into())), - url_parameters: vec![], - verification_uri: None, - verification_uri_complete: None, - user_code: None, - expires_in_seconds: None, - authorization_url: None, - }), - ProviderAuthMethodKindDto::CodegraffDevice => { - launch_codegraff_login(None).await?; - Ok(cli_login_session( - &input.provider_id, - "Codegraff login launched in Terminal. Complete the device-code flow there, then finish setup here.", - )) - } - ProviderAuthMethodKindDto::CodexDevice => { - launch_codegraff_login(Some("codex")).await?; - Ok(cli_login_session( - &input.provider_id, - "Codex login launched in Terminal. Complete the browser OAuth flow there, then finish setup here.", - )) - } - ProviderAuthMethodKindDto::KimiDevice => { - launch_codegraff_login(Some("kimi")).await?; - Ok(cli_login_session( - &input.provider_id, - "Kimi login launched in Terminal. Complete the device-code flow there, then finish setup here.", - )) - } - _ => anyhow::bail!("Unsupported auth method for {}", input.provider_id), - } - } - - /// Completes provider auth by delegating to `graff key set` for API keys. - pub async fn complete_provider_auth( - &self, - input: CompleteProviderAuthInput, - ) -> Result { - let provider_id = provider_from_auth_session_id(&input.auth_session_id); - let submitted_api_key = input - .api_key - .as_deref() - .map(str::trim) - .filter(|key| !key.is_empty()); - if let Some(api_key) = submitted_api_key { - run_codegraff_key_set(provider_id, api_key).await?; - } - let summary = provider_summary(provider_id).await?; - validate_provider_auth_completion(&summary, submitted_api_key.is_some())?; - Ok(summary) - } - - /// Deletes a provider's stored credential from the same locations the CLI - /// writes (macOS Keychain / `~/.simple-harness-keys.json`, plus the codegraff - /// and codex login files). A matching `_API_KEY` env var, if set, - /// still takes precedence and will keep the provider configured. - pub async fn remove_provider(&self, input: RemoveProviderInput) -> Result { - remove_stored_credential(&input.provider_id)?; - let summary = provider_summary(&input.provider_id).await?; - // If it's still configured after deleting stored creds, an env var is - // overriding it — surface that instead of appearing to do nothing. - if summary.configured { - if let Some(env_key) = provider_env_key(&input.provider_id) { - if std::env::var(&env_key) - .ok() - .filter(|value| !value.trim().is_empty()) - .is_some() - { - match find_env_definition(&env_key) { - Some((path, line)) => anyhow::bail!( - "Stored {provider} credential removed. ${env_key} is still set in {path}:{line}, so it is still being used.", - provider = input.provider_id - ), - None => anyhow::bail!( - "Stored {provider} credential removed. ${env_key} is still inherited by the running Codegraff app, so it is still being used.", - provider = input.provider_id - ), - } - } - } - } - Ok(summary) - } - - /// Lists slash commands available in the MVP adapter. - pub async fn list_commands(&self, _: Option) -> Result> { - Ok(vec![ - command("help", "Show available commands.", false), - command("agent", "Show active agent.", false), - command("model", "Open the model picker.", false), - command("goal", "Set/show the current objective.", true), - command("loop", "Run an autonomous plan→act→verify pass.", true), - bash_command(), - command("compact", "Compact current conversation.", true), - command("workspace-status", "Show git status.", true), - command( - "ultracode", - "Toggle persistent ultracode (multi-agent workflow) mode.", - true, - ), - ]) - } - - /// Sends a prompt and streams the reply from a persistent `graff --json` - /// session bound to the conversation. Incremental session snapshots are - /// emitted as text/tool events arrive, so the UI renders tokens live. - pub async fn send_prompt(&self, input: SendPromptInput) -> Result { - let conversation_id = input - .conversation_id - .clone() - .unwrap_or_else(|| format!("chat-{}", Uuid::new_v4().simple())); - let request_id = format!("request-{}", Uuid::new_v4().simple()); - let plan_mode = input.agent_id.as_deref() == Some("muse"); - let loop_mode = input.agent_id.as_deref() == Some("loop"); - let usage_model_pair = self.selected_model_pair().await; - let title_model_arg = usage_model_pair - .as_ref() - .map(|(provider, model)| prompt_model_arg(provider, model)); - let is_first_turn; - let engine_prompt; - let should_queue; - { - let mut state = self.state.lock().await; - set_active_workspace(&mut state, &input.workspace_path); - state.active_conversation_id = Some(conversation_id.clone()); - state - .selected_by_workspace - .insert(input.workspace_path.clone(), conversation_id.clone()); - let active_agent_id = state.active_agent_id.clone(); - let conversation = state - .conversations - .entry(conversation_id.clone()) - .or_insert_with(|| ConversationState { - workspace_path: input.workspace_path.clone(), - conversation_id: conversation_id.clone(), - title: title_from_prompt(&input.prompt), - messages: vec![], - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id, - plan_mode, - ultracode_enabled: false, - todos: Vec::new(), - goal: None, - updated_at: now_millis(), - }); - conversation.plan_mode = plan_mode; - conversation.updated_at = now_millis(); - let goal_prompt = conversation - .goal - .as_ref() - .map(|goal| format!("{}\n\n[harness goal: {}]", input.prompt, goal)) - .unwrap_or_else(|| input.prompt.clone()); - engine_prompt = if loop_mode { - format!( - "{goal_prompt}\n\n[harness note: /loop was used. Work autonomously until the prompt is satisfied: make a brief plan, execute it with tools, verify the result, and only stop when you can report completion or you need a required human decision. Keep iterations tight and avoid asking for confirmation between routine steps.]" - ) - } else { - goal_prompt - }; - is_first_turn = conversation.messages.is_empty(); - if is_first_turn && is_placeholder_title(&conversation.title) { - conversation.title = title_from_prompt(&input.prompt); - } - conversation.messages.push(SessionMessageDto::User { - id: format!("{request_id}-user"), - request_id: request_id.clone(), - text: input.prompt.clone(), - }); - // Remember which agent ("muse" planning / "forge" normal) this - // request ran under so the GUI's plan-decision card can light up - // after a planning-mode turn completes (gate keys off "muse"). - conversation.request_agent_ids.insert( - request_id.clone(), - input - .agent_id - .clone() - .unwrap_or_else(|| "forge".to_string()), - ); - should_queue = !conversation.active_request_ids.is_empty(); - if should_queue { - conversation.queued_prompts.push_back(QueuedPrompt { - agent_id: input.agent_id.clone(), - engine_prompt: engine_prompt.clone(), - request_id: request_id.clone(), - }); - } else { - conversation.active_request_ids.push(request_id.clone()); - } - } - - if let Some((provider_id, model_id)) = usage_model_pair.as_ref() { - self.record_model_usage(provider_id, model_id).await; - } - - // Managed chats are auto-created `chat_` folders; once a chat has a - // prompt, give the workspace a readable name derived from it (unless the - // user already named it). Projects keep their folder/display name. - if let Ok(Some(registration)) = self - .projects - .get_workspace_registration(Path::new(&input.workspace_path)) - { - if registration.kind == RegisteredWorkspaceKind::ManagedChat - && registration.display_name.is_none() - { - let _ = self.projects.set_workspace_display_name( - Path::new(&input.workspace_path), - Some(&title_from_prompt(&input.prompt)), - ); - } - } - - self.emit().await?; - - if should_queue { - return self.snapshot().await; - } - - if is_first_turn { - let manager = self.clone(); - let conversation_id = conversation_id.clone(); - let prompt = input.prompt.clone(); - tokio::spawn(async move { - manager - .generate_and_set_title(conversation_id, prompt, title_model_arg) - .await; - }); - } - - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ - "type": "set_mode", - "mode": if plan_mode { "plan" } else { "normal" }, - }), - ) - .await?; - } - - if let Err(error) = self - .stream_turn( - &conversation_id, - &request_id, - &input.workspace_path, - &engine_prompt, - ) - .await - { - let message = format_error_chain(&error); - let id = format!("{request_id}-error"); - let rid = request_id.clone(); - self.mutate_conversation(&conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::Error { - id, - request_id: rid, - message, - }); - }) - .await; - } - - let mut completed_request_id = request_id; - loop { - let next_prompt = { - let mut state = self.state.lock().await; - let Some(conversation) = state.conversations.get_mut(&conversation_id) else { - break; - }; - conversation - .active_request_ids - .retain(|id| id != &completed_request_id); - let next = conversation.queued_prompts.pop_front(); - if let Some(next) = &next { - conversation.plan_mode = next.agent_id.as_deref() == Some("muse"); - conversation.updated_at = now_millis(); - conversation - .active_request_ids - .push(next.request_id.clone()); - } - next - }; - - let Some(next_prompt) = next_prompt else { - break; - }; - - self.emit().await?; - - let next_plan_mode = next_prompt.agent_id.as_deref() == Some("muse"); - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ - "type": "set_mode", - "mode": if next_plan_mode { "plan" } else { "normal" }, - }), - ) - .await?; - } - - if let Err(error) = self - .stream_turn( - &conversation_id, - &next_prompt.request_id, - &input.workspace_path, - &next_prompt.engine_prompt, - ) - .await - { - let message = format_error_chain(&error); - let id = format!("{}-error", next_prompt.request_id); - let rid = next_prompt.request_id.clone(); - self.mutate_conversation(&conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::Error { - id, - request_id: rid, - message, - }); - }) - .await; - } - - completed_request_id = next_prompt.request_id; - } - - let snapshot = self.snapshot().await?; - let _ = self.emitter.emit_session_updated(snapshot.clone()); - self.persist_conversations().await; - Ok(snapshot) - } - - /// Drives one user turn over the conversation's `graff --json` session, - /// translating JSONL events into session messages and emitting after each. - async fn stream_turn( - &self, - conversation_id: &str, - request_id: &str, - workspace_path: &str, - prompt: &str, - ) -> Result<()> { - let mut io = self - .acquire_session_io(conversation_id, workspace_path) - .await?; - - let line = serde_json::json!({ "type": "user", "text": prompt }).to_string(); - if let Err(error) = write_session_line(&io.stdin, &line).await { - self.drop_session(conversation_id).await; - return Err(anyhow::Error::new(error).context("Failed to send prompt to graff session")); - } - - let mut current_assistant: Option = None; - let mut assistant_seq: usize = 0; - let mut current_reasoning: Option = None; - let mut reasoning_seq: usize = 0; - loop { - let next = io.reader.next_line().await; - let line = match next { - Ok(Some(line)) => line, - // EOF: the child exited (a stop_prompt kill, or it crashed). - // End the turn keeping whatever streamed so far; the dead - // session is dropped so the next prompt respawns it. - Ok(None) => { - self.drop_session(conversation_id).await; - return Ok(()); - } - Err(error) => { - self.drop_session(conversation_id).await; - return Err( - anyhow::Error::new(error).context("Failed reading graff session output") - ); - } - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let event: serde_json::Value = match serde_json::from_str(trimmed) { - Ok(event) => event, - Err(_) => continue, - }; - match event.get("type").and_then(serde_json::Value::as_str) { - Some("text") => { - let delta = event - .get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if delta.is_empty() { - continue; - } - current_reasoning = None; - let id = current_assistant.get_or_insert_with(|| { - let id = format!("{request_id}-assistant-{assistant_seq}"); - assistant_seq += 1; - id - }); - let id = id.clone(); - let rid = request_id.to_string(); - let delta = delta.to_string(); - self.mutate_conversation(conversation_id, move |conversation| { - let existing = - conversation.messages.iter_mut().rev().find_map( - |message| match message { - SessionMessageDto::Assistant { id: mid, text, .. } - if *mid == id => - { - Some(text) - } - _ => None, - }, - ); - match existing { - Some(text) => text.push_str(&delta), - None => conversation.messages.push(SessionMessageDto::Assistant { - id, - request_id: rid, - text: delta, - }), - } - }) - .await; - self.emit().await?; - } - Some("reasoning") => { - let delta = event - .get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if delta.is_empty() { - continue; - } - current_assistant = None; - let id = current_reasoning.get_or_insert_with(|| { - let id = format!("{request_id}-reasoning-{reasoning_seq}"); - reasoning_seq += 1; - id - }); - let id = id.clone(); - let rid = request_id.to_string(); - let delta = delta.to_string(); - self.mutate_conversation(conversation_id, move |conversation| { - let existing = - conversation.messages.iter_mut().rev().find_map( - |message| match message { - SessionMessageDto::Reasoning { id: mid, text, .. } - if *mid == id => - { - Some(text) - } - _ => None, - }, - ); - match existing { - Some(text) => text.push_str(&delta), - None => conversation.messages.push(SessionMessageDto::Reasoning { - id, - request_id: rid, - text: delta, - }), - } - }) - .await; - self.emit().await?; - } - Some("tool_call") => { - current_assistant = None; - let name = event - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("tool") - .to_string(); - let input = event.get("input"); - // Older graff binaries surfaced ask_user as a normal - // tool_call and expected a plain answer line. Keep that - // fallback distinct from the structured ask_user event. - if name == "ask_user" { - let followup = build_followup_request( - conversation_id, - request_id, - workspace_path, - input, - Some(format!("legacy-{}", Uuid::new_v4().simple())), - ); - let id = format!("{request_id}-tool-{}", Uuid::new_v4().simple()); - let rid = request_id.to_string(); - let call_id = None; - let question = followup.question.clone(); - self.mutate_conversation(conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::ToolStart { - id, - request_id: rid, - name: "ask_user".to_string(), - call_id, - detail: ToolCallDetailDto::Followup { question }, - }); - }) - .await; - self.state - .lock() - .await - .pending_followups - .insert(conversation_id.to_string(), followup); - self.emit().await?; - continue; - } - let detail = tool_call_detail(&name, input); - // A `todo_write` replaces the whole task list (harness - // semantics). Capture the items so the task-progress dock - // can render them; the op itself stays hidden in the feed. - let todos = if name == "todo_write" { - Some(parse_todo_items(input)) - } else { - None - }; - let id = format!("{request_id}-tool-{}", Uuid::new_v4().simple()); - let rid = request_id.to_string(); - self.mutate_conversation(conversation_id, move |conversation| { - if let Some(todos) = todos { - conversation.todos = todos; - } - conversation.messages.push(SessionMessageDto::ToolStart { - id, - request_id: rid, - name, - call_id: None, - detail, - }); - }) - .await; - self.emit().await?; - } - Some("ask_user") => { - current_assistant = None; - let input = event.get("input"); - let call_id = event - .get("call_id") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - .filter(|id| !id.is_empty()) - .unwrap_or_else(|| format!("ask-user-{}", Uuid::new_v4().simple())); - let followup = build_followup_request( - conversation_id, - request_id, - workspace_path, - input, - Some(call_id.clone()), - ); - let id = format!("{request_id}-tool-{}", Uuid::new_v4().simple()); - let rid = request_id.to_string(); - let question = followup.question.clone(); - self.mutate_conversation(conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::ToolStart { - id, - request_id: rid, - name: "ask_user".to_string(), - call_id: Some(call_id), - detail: ToolCallDetailDto::Followup { question }, - }); - }) - .await; - self.state - .lock() - .await - .pending_followups - .insert(conversation_id.to_string(), followup); - self.emit().await?; - } - Some("tool_result") => { - current_assistant = None; - let name = event - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("tool") - .to_string(); - let is_error = event - .get("is_error") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let text = event - .get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let summary = tool_result_summary(&text); - let detail = if text.is_empty() { - None - } else { - Some(ToolResultDetailDto::Text { text }) - }; - let id = format!("{request_id}-toolend-{}", Uuid::new_v4().simple()); - let rid = request_id.to_string(); - self.mutate_conversation(conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::ToolEnd { - id, - request_id: rid, - name, - call_id: None, - summary, - is_error, - detail, - }); - }) - .await; - self.emit().await?; - } - Some("turn") => break, - Some("error") => { - let raw_message = event - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("graff error") - .to_string(); - let auth_error = is_provider_auth_error_message(&raw_message); - let message = if auth_error { - provider_auth_error_message(&raw_message) - } else { - raw_message - }; - let id = format!("{request_id}-error-{}", Uuid::new_v4().simple()); - let rid = request_id.to_string(); - self.mutate_conversation(conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::Error { - id, - request_id: rid, - message, - }); - }) - .await; - if auth_error { - // A rejected/expired credential can leave the long-lived - // child in a bad state. Drop it so retrying after the - // user reconnects spawns a fresh graff process instead - // of reusing a session that already failed auth. - self.drop_session(conversation_id).await; - self.emit().await?; - return Ok(()); - } - break; - } - // system_prompt / score acks and anything unrecognized: ignore. - _ => {} - } - } - - // Turn completed cleanly; hand the IO back to the session for reuse. - self.release_session_io(conversation_id, io).await; - Ok(()) - } - - /// Sends one non-user control request to a live `graff --json` session and returns its ack event. - async fn send_control( - &self, - conversation_id: &str, - request: serde_json::Value, - ) -> Result { - let mut sessions = self.sessions.lock().await; - let session = sessions - .get_mut(conversation_id) - .context("No live graff session for this conversation")?; - let reader = session - .reader - .take() - .context("a prompt is already running for this conversation")?; - let mut io = GraffSessionIo { - stdin: session.stdin.clone(), - reader, - }; - drop(sessions); - - if let Err(error) = write_session_line(&io.stdin, &request.to_string()).await { - self.drop_session(conversation_id).await; - return Err(anyhow::Error::new(error) - .context("Failed to send control request to graff session")); - } - - let ack = loop { - let line = match io.reader.next_line().await { - Ok(Some(line)) => line, - Ok(None) => { - self.drop_session(conversation_id).await; - anyhow::bail!("graff session exited before acknowledging control request"); - } - Err(error) => { - self.drop_session(conversation_id).await; - return Err( - anyhow::Error::new(error).context("Failed reading graff control response") - ); - } - }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let Ok(event) = serde_json::from_str::(trimmed) else { - continue; - }; - match event.get("type").and_then(serde_json::Value::as_str) { - Some("model" | "compact" | "mode" | "agent" | "effort" | "fast" | "ultracode") => { - break event; - } - Some("error") => { - let message = event - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("graff control request failed"); - self.release_session_io(conversation_id, io).await; - anyhow::bail!(message.to_string()); - } - _ => {} - } - }; - - self.release_session_io(conversation_id, io).await; - Ok(ack) - } - - /// Builds the current snapshot and pushes it to the UI. - async fn emit(&self) -> Result<()> { - let snapshot = self.snapshot().await?; - let _ = self.emitter.emit_session_updated(snapshot); - Ok(()) - } - - /// Writes non-empty transcripts to disk so chats survive a restart. - /// Called at turn boundaries / structural changes, not per token. - async fn persist_conversations(&self) { - let items: Vec = { - let state = self.state.lock().await; - state - .conversations - .values() - .filter(|conversation| !conversation.messages.is_empty()) - .map(|conversation| PersistedConversation { - workspace_path: conversation.workspace_path.clone(), - conversation_id: conversation.conversation_id.clone(), - title: conversation.title.clone(), - messages: conversation.messages.clone(), - active_agent_id: conversation.active_agent_id.clone(), - plan_mode: conversation.plan_mode, - ultracode_enabled: conversation.ultracode_enabled, - goal: conversation.goal.clone(), - updated_at: conversation.updated_at, - }) - .collect() - }; - let Some(path) = conversations_store_path() else { - return; - }; - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); - } - if let Ok(text) = serde_json::to_string(&items) { - let _ = std::fs::write(&path, text); - } - } - - /// Applies a mutation to one conversation under the state lock. - async fn mutate_conversation( - &self, - conversation_id: &str, - f: F, - ) { - let mut state = self.state.lock().await; - if let Some(conversation) = state.conversations.get_mut(conversation_id) { - f(conversation); - } - } - - /// Returns the conversation's session IO, spawning the `graff --json` child - /// on first use. Errors if a turn is already streaming for the conversation. - async fn acquire_session_io( - &self, - conversation_id: &str, - workspace_path: &str, - ) -> Result { - let model_arg = self.selected_model_arg().await; - let mut sessions = self.sessions.lock().await; - if !sessions.contains_key(conversation_id) { - let (active_agent_id, plan_mode, effort, fast, ultracode) = { - let state = self.state.lock().await; - let conversation = state.conversations.get(conversation_id); - ( - conversation - .and_then(|conversation| conversation.active_agent_id.clone()) - .or_else(|| state.active_agent_id.clone()), - conversation - .map(|conversation| conversation.plan_mode) - .unwrap_or(false), - state.selected_effort.clone(), - state.fast_enabled, - conversation.map(|c| c.ultracode_enabled).unwrap_or(false), - ) - }; - let session = - spawn_graff_session(workspace_path, model_arg.as_deref(), conversation_id)?; - sessions.insert(conversation_id.to_string(), session); - drop(sessions); - if let Some(agent_id) = active_agent_id.filter(|id| id != "forge") { - self.send_control( - conversation_id, - serde_json::json!({ "type": "set_agent", "id": agent_id }), - ) - .await?; - } - if plan_mode { - self.send_control( - conversation_id, - serde_json::json!({ "type": "set_mode", "mode": "plan" }), - ) - .await?; - } - if let Some(level) = effort { - self.send_control( - conversation_id, - serde_json::json!({ "type": "set_effort", "level": level }), - ) - .await?; - } - if fast { - self.send_control( - conversation_id, - serde_json::json!({ "type": "set_fast", "on": true }), - ) - .await?; - } - if ultracode { - self.send_control( - conversation_id, - serde_json::json!({ "type": "set_ultracode", "on": true }), - ) - .await?; - } - sessions = self.sessions.lock().await; - } - let session = sessions - .get_mut(conversation_id) - .expect("session inserted above"); - let reader = session - .reader - .take() - .context("a prompt is already running for this conversation")?; - Ok(GraffSessionIo { - stdin: session.stdin.clone(), - reader, - }) - } - - /// Returns the session reader so the next turn on the conversation can reuse - /// it. The stdin handle stays in the session map, so this only restores the - /// reader. - async fn release_session_io(&self, conversation_id: &str, io: GraffSessionIo) { - let mut sessions = self.sessions.lock().await; - if let Some(session) = sessions.get_mut(conversation_id) { - session.reader = Some(io.reader); - } - } - - /// Kills and forgets the conversation's session (used by stop / on failure). - /// Any unanswered `ask_user` prompt dies with the child, so clear it too. - async fn drop_session(&self, conversation_id: &str) { - let mut sessions = self.sessions.lock().await; - if let Some(mut session) = sessions.remove(conversation_id) { - let _ = session.child.start_kill(); - } - drop(sessions); - self.state - .lock() - .await - .pending_followups - .remove(conversation_id); - } - - /// Returns whether a live session exists for the conversation. - async fn session_exists(&self, conversation_id: &str) -> bool { - self.sessions.lock().await.contains_key(conversation_id) - } - - /// Runs an MVP slash command. - pub async fn run_command( - &self, - name: String, - args: Vec, - workspace_path: Option, - conversation_id: Option, - ) -> Result { - match name.as_str() { - "agent" => Ok(CommandRunResultDto { - title: "/agent".into(), - body: Some("Active agent: Forge".into()), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Agents, - payload: Some(CommandPayloadDto::Agents(self.agents_payload().await)), - }), - "ultracode" => { - let conversation_id = conversation_id.context("Missing conversation")?; - let now_on = { - let mut state = self.state.lock().await; - let conversation = state - .conversations - .get_mut(&conversation_id) - .context("Conversation not found")?; - conversation.ultracode_enabled = !conversation.ultracode_enabled; - conversation.ultracode_enabled - }; - self.persist_conversations().await; - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ "type": "set_ultracode", "on": now_on }), - ) - .await?; - } - let body = if now_on { - "Ultracode mode enabled for this chat." - } else { - "Ultracode mode disabled for this chat." - }; - Ok(CommandRunResultDto { - title: "/ultracode".into(), - body: Some(body.into()), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }) - } - "goal" => { - let _workspace = workspace_path.context("Missing workspace")?; - let conversation_id = conversation_id.context("Missing conversation")?; - let text = args.join(" ").trim().to_string(); - let body = if text.is_empty() { - let current = { - let state = self.state.lock().await; - state - .conversations - .get(&conversation_id) - .and_then(|conversation| conversation.goal.clone()) - }; - current - .map(|goal| format!("Current goal: {goal}\n\nClear it with /goal clear.")) - .unwrap_or_else(|| "No active goal. Set one with /goal .".into()) - } else if text.eq_ignore_ascii_case("clear") || text.eq_ignore_ascii_case("off") { - self.mutate_conversation(&conversation_id, |conversation| { - conversation.goal = None; - }) - .await; - self.persist_conversations().await; - "Goal cleared. Future turns will not get goal steering.".into() - } else { - let goal = text.clone(); - self.mutate_conversation(&conversation_id, move |conversation| { - conversation.goal = Some(goal); - }) - .await; - self.persist_conversations().await; - format!( - "Goal set: {text}\n\nFuture turns in this chat will include this objective as steering." - ) - }; - let snapshot = self.snapshot().await?; - let _ = self.emitter.emit_session_updated(snapshot.clone()); - Ok(CommandRunResultDto { - title: "/goal".into(), - body: Some(body), - snapshot: Some(snapshot), - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }) - } - "loop" => { - let prompt = args.join(" ").trim().to_string(); - if prompt.is_empty() { - return Ok(CommandRunResultDto { - title: "/loop".into(), - body: Some( - "Usage: /loop — run an autonomous plan→act→verify pass." - .into(), - ), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }); - } - let workspace = workspace_path.context("Missing workspace")?; - let snapshot = self - .send_prompt(SendPromptInput { - workspace_path: workspace, - conversation_id, - prompt, - agent_id: Some("loop".into()), - }) - .await?; - Ok(CommandRunResultDto { - title: "/loop".into(), - body: None, - snapshot: Some(snapshot), - saved_path: None, - result_kind: CommandResultKindDto::Snapshot, - payload: None, - }) - } - "compact" => Ok(CommandRunResultDto { - title: "/compact".into(), - body: None, - snapshot: Some( - self.compact_conversation( - workspace_path.context("Missing workspace")?, - conversation_id.context("Missing conversation")?, - ) - .await?, - ), - saved_path: None, - result_kind: CommandResultKindDto::Snapshot, - payload: None, - }), - "bash" => { - let workspace = self - .resolve_workspace(workspace_path) - .await - .context("Missing workspace")?; - let shell_command = args.join(" ").trim().to_string(); - if shell_command.is_empty() { - return Ok(CommandRunResultDto { - title: "/bash".into(), - body: Some("usage: /bash ".into()), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }); - } - Ok(CommandRunResultDto { - title: format!("/bash {shell_command}"), - body: Some(run_shell_command(&workspace, &shell_command)?), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }) - } - "workspace-status" => { - let workspace = workspace_path.unwrap_or_default(); - Ok(CommandRunResultDto { - title: "/workspace-status".into(), - body: Some("Workspace status loaded.".into()), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::WorkspaceStatus, - payload: Some(CommandPayloadDto::WorkspaceStatus( - WorkspaceStatusPayloadDto { - workspace_path: workspace.clone(), - files: git_status_files(&workspace).unwrap_or_default(), - }, - )), - }) - } - _ => Ok(CommandRunResultDto { - title: format!("/{name}"), - body: Some(format!( - "Available MVP commands: /help, /agent, /goal, /loop, /bash , /compact, /workspace-status. Args: {}", - args.join(" ") - )), - snapshot: None, - saved_path: None, - result_kind: CommandResultKindDto::Text, - payload: None, - }), - } - } - - /// Returns a simple workspace sync payload. - pub async fn workspace_sync(&self, workspace_path: String) -> Result { - Ok(WorkspaceSyncPayloadDto { - workspace_path, - events: vec!["Zig-native GUI MVP does not maintain a workspace index yet.".into()], - }) - } - - /// Text search over the workspace (ripgrep, falling back to `git grep`). - /// This is a literal/substring search, not the semantic index the original - /// GUI used — graff's codedb index isn't exposed as a CLI for the adapter. - pub async fn workspace_query( - &self, - input: WorkspaceQueryInput, - ) -> Result { - let limit = input.limit.unwrap_or(50).min(500); - let results = if input.query.trim().is_empty() { - vec![] - } else { - workspace_text_search( - &input.workspace_path, - &input.query, - input.ends_with.as_deref(), - input.starts_with.as_deref(), - limit, - ) - }; - Ok(WorkspaceSearchPayloadDto { - workspace_path: input.workspace_path, - query: input.query, - results, - }) - } - - /// Builds a placeholder workflow draft. - pub async fn build_workflow_draft( - &self, - input: WorkflowDraftInput, - ) -> Result { - Ok(WorkflowDraftPayloadDto { - goal: input.goal.clone(), - summary: "Workflow drafting is stubbed in the Zig-native GUI MVP.".into(), - nodes: vec![], - export_text: format!("goal: {}\nnodes: []", input.goal), - approved_prompt: input.goal, - trace: vec![], - }) - } - - /// Exports a workflow draft. - pub fn export_workflow_draft(&self, draft: WorkflowDraftPayloadDto) -> String { - draft.export_text - } - - /// Sets the active agent and applies it to the live conversation when present. - /// Sets the reasoning-effort level (low|medium|high). Stored for future - /// sessions and forwarded to the active session if one is live. - pub async fn set_effort(&self, level: String, _: Option) -> Result<()> { - let conversation_id = { - let mut state = self.state.lock().await; - state.selected_effort = Some(level.clone()); - state.active_conversation_id.clone() - }; - if let Some(conversation_id) = conversation_id { - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ "type": "set_effort", "level": level }), - ) - .await?; - } - } - Ok(()) - } - - /// Toggles Codex "fast" mode (priority service tier). Stored for future - /// sessions and forwarded to the active session if one is live. - pub async fn set_fast(&self, on: bool, _: Option) -> Result<()> { - let conversation_id = { - let mut state = self.state.lock().await; - state.fast_enabled = on; - state.active_conversation_id.clone() - }; - if let Some(conversation_id) = conversation_id { - if self.session_exists(&conversation_id).await { - self.send_control( - &conversation_id, - serde_json::json!({ "type": "set_fast", "on": on }), - ) - .await?; - } - } - self.persist_prompt_settings().await; - Ok(()) - } - - pub async fn set_active_agent( - &self, - agent_id: String, - _: Option, - ) -> Result { - let conversation_id = { - let mut state = self.state.lock().await; - state.active_agent_id = Some(agent_id.clone()); - let conversation_id = state.active_conversation_id.clone(); - if let Some(conversation_id) = &conversation_id { - if let Some(conversation) = state.conversations.get_mut(conversation_id) { - conversation.active_agent_id = Some(agent_id.clone()); - } - } - conversation_id - }; - if let Some(conversation_id) = conversation_id { - if self.session_exists(&conversation_id).await { - let id = if agent_id == "forge" { - "" - } else { - agent_id.as_str() - }; - self.send_control( - &conversation_id, - serde_json::json!({ "type": "set_agent", "id": id }), - ) - .await?; - } - } - Ok(self.agents_payload().await) - } - - /// Lists MCP servers configured in the workspace `.mcp.json`. - pub async fn list_mcp_servers( - &self, - workspace_path: Option, - ) -> Result { - let servers = match self.resolve_workspace(workspace_path).await { - Some(workspace) => read_mcp_servers(&workspace), - None => vec![], - }; - Ok(McpSettingsPayloadDto { servers }) - } - - /// Merges imported `mcpServers` entries into the workspace `.mcp.json`. - pub async fn import_mcp_config(&self, input: McpImportInput) -> Result { - let workspace = self - .resolve_workspace(None) - .await - .context("No active workspace to import MCP config into")?; - let incoming: serde_json::Value = - serde_json::from_str(&input.json).context("Invalid MCP config JSON")?; - // Accept either {"mcpServers": {...}} or a bare {name: {...}} map. - let incoming = incoming.get("mcpServers").cloned().unwrap_or(incoming); - let incoming = incoming - .as_object() - .context("MCP config must be an object of servers")?; - - let mut config = - read_mcp_config(&workspace).unwrap_or_else(|| serde_json::json!({ "mcpServers": {} })); - let servers = config - .as_object_mut() - .context("`.mcp.json` is not a JSON object")? - .entry("mcpServers") - .or_insert_with(|| serde_json::json!({})); - let servers = servers - .as_object_mut() - .context("`mcpServers` is not a JSON object")?; - for (name, server) in incoming { - servers.insert(name.clone(), server.clone()); - } - write_mcp_config(&workspace, &config)?; - self.list_mcp_servers(Some(workspace)).await - } - - /// Removes a server from the workspace `.mcp.json`. - pub async fn remove_mcp_server( - &self, - input: McpServerActionInput, - ) -> Result { - let workspace = self - .resolve_workspace(None) - .await - .context("No active workspace")?; - if let Some(mut config) = read_mcp_config(&workspace) { - if let Some(servers) = config - .get_mut("mcpServers") - .and_then(serde_json::Value::as_object_mut) - { - servers.remove(&input.name); - } - write_mcp_config(&workspace, &config)?; - } - self.list_mcp_servers(Some(workspace)).await - } - - /// Re-reads `.mcp.json`. New entries apply to sessions spawned afterwards; - /// live sessions keep the MCP set they started with. - pub async fn reload_mcp_servers( - &self, - workspace_path: Option, - ) -> Result { - self.list_mcp_servers(workspace_path).await - } - - /// MCP OAuth login — not applicable: graff runs stdio MCP servers only. - pub async fn login_mcp_server(&self, _: McpServerActionInput) -> Result { - anyhow::bail!( - "graff runs stdio MCP servers (command/args) only; OAuth login does not apply" - ) - } - - /// MCP OAuth logout — not applicable: graff runs stdio MCP servers only. - pub async fn logout_mcp_server( - &self, - _: McpServerActionInput, - ) -> Result { - anyhow::bail!( - "graff runs stdio MCP servers (command/args) only; OAuth logout does not apply" - ) - } - - /// Resolves the workspace to act on: the argument if set, else the active one. - async fn resolve_workspace(&self, workspace_path: Option) -> Option { - if let Some(workspace) = workspace_path.filter(|workspace| !workspace.is_empty()) { - return Some(workspace); - } - self.state.lock().await.active_workspace_path.clone() - } - - /// Compacts the live conversation and records the real compaction result. - pub async fn compact_conversation( - &self, - workspace_path: String, - conversation_id: String, - ) -> Result { - { - let mut state = self.state.lock().await; - set_active_workspace(&mut state, &workspace_path); - } - let ack = self - .send_control(&conversation_id, serde_json::json!({ "type": "compact" })) - .await?; - let chars = ack - .get("chars") - .and_then(serde_json::Value::as_u64) - .unwrap_or(0); - self.mutate_conversation(&conversation_id, move |conversation| { - let request_id = format!("compact-{}", Uuid::new_v4().simple()); - conversation - .messages - .push(SessionMessageDto::ContextCompacted { - id: request_id.clone(), - request_id, - text: format!("Conversation compacted to a {chars}-character summary."), - }); - }) - .await; - self.persist_conversations().await; - self.snapshot().await - } - - /// Stops a prompt in the local GUI state. - pub async fn stop_prompt(&self, input: ChatBindingDto) -> Result<()> { - // Killing the session child makes the in-flight stream_turn read loop - // hit EOF and finish, keeping whatever streamed so far. The next prompt - // respawns a fresh session. - self.drop_session(&input.conversation_id).await; - self.mutate_conversation(&input.conversation_id, |conversation| { - conversation.active_request_ids.clear(); - conversation.queued_prompts.clear(); - }) - .await; - let _ = self.emit().await; - Ok(()) - } - - /// Selects a conversation. - pub async fn select_conversation( - &self, - workspace_path: String, - conversation_id: String, - ) -> Result { - let mut state = self.state.lock().await; - set_active_workspace(&mut state, &workspace_path); - if state.conversations.contains_key(&conversation_id) { - state.active_conversation_id = Some(conversation_id.clone()); - state - .selected_by_workspace - .insert(workspace_path, conversation_id); - } else if let Some(fallback_id) = first_workspace_conversation_id(&state, &workspace_path) { - state.active_conversation_id = Some(fallback_id.clone()); - state - .selected_by_workspace - .insert(workspace_path, fallback_id); - } else { - state.active_conversation_id = None; - state.selected_by_workspace.remove(&workspace_path); - } - drop(state); - self.snapshot().await - } - - /// Ensures a conversation view exists. - pub async fn ensure_conversation_view( - &self, - workspace_path: String, - conversation_id: String, - ) -> Result { - self.select_conversation(workspace_path, conversation_id) - .await - } - - /// Starts a new chat in a workspace. - pub async fn start_new_chat(&self, workspace_path: String) -> Result { - let conversation_id = format!("chat-{}", Uuid::new_v4().simple()); - let mut state = self.state.lock().await; - set_active_workspace(&mut state, &workspace_path); - state.active_conversation_id = Some(conversation_id.clone()); - state - .selected_by_workspace - .insert(workspace_path.clone(), conversation_id.clone()); - state.conversations.insert( - conversation_id.clone(), - ConversationState { - workspace_path, - conversation_id, - title: "New chat".into(), - messages: vec![], - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id: None, - plan_mode: false, - ultracode_enabled: false, - todos: Vec::new(), - goal: None, - updated_at: now_millis(), - }, - ); - drop(state); - self.snapshot().await - } - - /// Creates a managed chat workspace. - pub async fn create_managed_chat(&self) -> Result { - self.open_workspace(self.projects.create_managed_chat_workspace()?) - .await - } - - /// Handles chat handoff as a no-op in the MVP adapter. - pub async fn handoff_chat(&self, _: HandoffChatInput) -> Result { - self.snapshot().await - } - - /// Answers an in-flight `ask_user` prompt: writes the reply to the graff - /// child's stdin (unblocking it), clears the pending followup, and lets the - /// still-running stream_turn loop pick up the resulting tool_result. - pub async fn respond_followup( - &self, - response: FollowupResponseDto, - ) -> Result { - let request = { - let mut state = self.state.lock().await; - let conversation_id = - state - .pending_followups - .iter() - .find_map(|(conversation_id, request)| { - (request.followup_id == response.followup_id) - .then(|| conversation_id.clone()) - }); - conversation_id.and_then(|id| state.pending_followups.remove(&id)) - }; - // No matching prompt (already answered, or the session was dropped): - // just return the current snapshot so the UI reconciles. - let Some(request) = request else { - return self.snapshot().await; - }; - - let answer = followup_answer_line(&request, &response); - let stdin = { - let sessions = self.sessions.lock().await; - sessions - .get(&request.conversation_id) - .map(|session| session.stdin.clone()) - }; - if let Some(stdin) = stdin { - if let Err(error) = write_session_line(&stdin, &answer).await { - self.drop_session(&request.conversation_id).await; - return Err(anyhow::Error::new(error) - .context("Failed to send follow-up answer to graff session")); - } - } - - if request.followup_id.starts_with("legacy-") { - let name = "ask_user".to_string(); - let summary = tool_result_summary(&answer); - let detail = if answer.is_empty() { - None - } else { - Some(ToolResultDetailDto::Text { - text: answer.clone(), - }) - }; - let id = format!("{}-toolend-{}", request.request_id, Uuid::new_v4().simple()); - let rid = request.request_id.clone(); - self.mutate_conversation(&request.conversation_id, move |conversation| { - conversation.messages.push(SessionMessageDto::ToolEnd { - id, - request_id: rid, - name, - call_id: None, - summary, - is_error: response.cancelled, - detail, - }); - }) - .await; - } - - self.emit().await?; - self.snapshot().await - } - - /// Archives a conversation from local state. - pub async fn archive_conversation( - &self, - workspace_path: String, - conversation_id: String, - ) -> Result { - self.drop_session(&conversation_id).await; - let mut state = self.state.lock().await; - state.conversations.remove(&conversation_id); - if state.selected_by_workspace.get(&workspace_path) == Some(&conversation_id) { - state.selected_by_workspace.remove(&workspace_path); - } - state - .selected_by_workspace - .retain(|_, selected_id| selected_id != &conversation_id); - if state.active_conversation_id.as_deref() == Some(&conversation_id) { - if state.active_workspace_path.as_deref() == Some(&workspace_path) { - state.active_conversation_id = - first_workspace_conversation_id(&state, &workspace_path); - } else { - state.active_conversation_id = None; - } - } - drop(state); - self.persist_conversations().await; - self.snapshot().await - } - - /// Archives a workspace from the local registry. - pub async fn archive_workspace(&self, workspace_path: String) -> Result { - self.projects - .archive_workspace(Path::new(&workspace_path))?; - let conversation_ids: Vec = { - let state = self.state.lock().await; - state - .conversations - .values() - .filter(|conversation| conversation.workspace_path == workspace_path) - .map(|conversation| conversation.conversation_id.clone()) - .collect() - }; - for conversation_id in &conversation_ids { - self.drop_session(conversation_id).await; - } - let removed_conversation_ids: HashSet = conversation_ids.into_iter().collect(); - let mut state = self.state.lock().await; - state.workspaces.retain(|path| path != &workspace_path); - state - .conversations - .retain(|_, conversation| conversation.workspace_path != workspace_path); - state.selected_by_workspace.remove(&workspace_path); - state - .selected_by_workspace - .retain(|_, conversation_id| !removed_conversation_ids.contains(conversation_id)); - if state.active_workspace_path.as_deref() == Some(&workspace_path) { - state.active_workspace_path = state.workspaces.first().cloned(); - state.active_conversation_id = state - .active_workspace_path - .clone() - .and_then(|path| first_workspace_conversation_id(&state, &path)); - } else if state - .active_conversation_id - .as_ref() - .is_some_and(|conversation_id| removed_conversation_ids.contains(conversation_id)) - { - state.active_conversation_id = state - .active_workspace_path - .clone() - .and_then(|path| first_workspace_conversation_id(&state, &path)); - } - drop(state); - self.persist_conversations().await; - self.snapshot().await - } - - /// Renames a workspace display name. - pub async fn rename_workspace( - &self, - workspace_path: String, - display_name: Option, - ) -> Result { - self.projects - .set_workspace_display_name(Path::new(&workspace_path), display_name.as_deref())?; - self.snapshot().await - } - - /// Checks out a git branch. - pub async fn checkout_git_branch( - &self, - workspace_path: String, - branch_name: String, - ) -> Result { - run_git(&workspace_path, &["checkout", &branch_name])?; - self.get_runtime_status(Some(workspace_path)).await - } - - /// Creates a git branch. - pub async fn create_git_branch( - &self, - workspace_path: String, - branch_name: String, - ) -> Result { - run_git(&workspace_path, &["checkout", "-b", &branch_name])?; - self.get_runtime_status(Some(workspace_path)).await - } - - /// Commits git changes. - pub async fn commit_git_changes( - &self, - workspace_path: String, - message: String, - ) -> Result { - run_git(&workspace_path, &["add", "-A"])?; - run_git(&workspace_path, &["commit", "-m", &message])?; - self.get_runtime_status(Some(workspace_path)).await - } - - /// Pushes the current git branch. - pub async fn push_git_branch(&self, workspace_path: String) -> Result { - run_git(&workspace_path, &["push", "-u", "origin", "HEAD"])?; - self.get_runtime_status(Some(workspace_path)).await - } - - /// Opens a workspace in the default app. - pub async fn open_in_target(&self, workspace_path: String, _: String) -> Result<()> { - desktop_open::open_path_default(Path::new(&workspace_path)) - } - - /// Opens a path in the default app. - pub async fn open_path_in_target( - &self, - workspace_path: String, - _: String, - path: String, - ) -> Result<()> { - desktop_open::open_path_default(&Path::new(&workspace_path).join(path)) - } - - /// Saves a conversation layout. - pub async fn save_conversation_layout(&self, input: SaveConversationLayoutInput) -> Result<()> { - self.projects - .save_conversation_layout(&input.conversation_id, &input.layout_json) - } - - /// Returns a conversation layout. - pub async fn get_conversation_layout(&self, conversation_id: String) -> Result> { - self.projects.get_conversation_layout(&conversation_id) - } - - /// Creates a saved workspace. - pub async fn create_saved_workspace( - &self, - input: CreateSavedWorkspaceInput, - ) -> Result { - let record = self.projects.create_saved_workspace( - &Uuid::new_v4().to_string(), - "Saved workspace", - &input.layout_json, - )?; - Ok(saved_detail( - record.id, - record.name, - record.layout_json, - record.updated_at, - )) - } - - /// Updates a saved workspace layout. - pub async fn update_saved_workspace_layout( - &self, - input: UpdateSavedWorkspaceLayoutInput, - ) -> Result { - let record = self - .projects - .update_saved_workspace_layout(&input.workspace_id, &input.layout_json)?; - Ok(saved_detail( - record.id, - record.name, - record.layout_json, - record.updated_at, - )) - } - - /// Returns a saved workspace. - pub async fn get_saved_workspace( - &self, - workspace_id: String, - ) -> Result> { - Ok(self - .projects - .get_saved_workspace(&workspace_id)? - .map(|record| { - saved_detail( - record.id, - record.name, - record.layout_json, - record.updated_at, - ) - })) - } - - /// Renames a saved workspace. - pub async fn rename_saved_workspace( - &self, - workspace_id: String, - name: String, - ) -> Result { - self.projects.rename_saved_workspace(&workspace_id, &name)?; - self.snapshot().await - } - - /// Deletes a saved workspace. - pub async fn delete_saved_workspace(&self, workspace_id: String) -> Result { - self.projects.delete_saved_workspace(&workspace_id)?; - self.snapshot().await - } - - async fn agents_payload(&self) -> AgentsPayloadDto { - let active = self - .state - .lock() - .await - .active_agent_id - .clone() - .unwrap_or_else(|| "forge".into()); - AgentsPayloadDto { - active_agent_id: Some(active.clone()), - selected_provider_id: Some("codegraff".into()), - selected_model_id: Some("default".into()), - selected_reasoning_effort: None, - agents: vec![AgentSummaryDto { - id: "forge".into(), - title: "Forge".into(), - description: Some("Default Codegraff assistant.".into()), - is_active: active == "forge", - model_id: Some("default".into()), - }], - } - } - - async fn snapshot(&self) -> Result { - let mut state = self.state.lock().await; - let registered = self.projects.list_workspaces().unwrap_or_default(); - for workspace in ®istered { - let path = workspace.path.to_string_lossy().into_owned(); - if !state.workspaces.contains(&path) { - state.workspaces.push(path); - } - } - // path -> (kind, display_name): routes managed chats to the "Chats" - // section (vs "Projects") and supplies their readable name. - let registrations: HashMap)> = registered - .into_iter() - .map(|workspace| { - let kind = match workspace.kind { - RegisteredWorkspaceKind::ManagedChat => WorkspaceKindDto::ManagedChat, - RegisteredWorkspaceKind::Project => WorkspaceKindDto::Project, - }; - ( - workspace.path.to_string_lossy().into_owned(), - (kind, workspace.display_name), - ) - }) - .collect(); - normalize_runtime_selection(&mut state); - let active_conversation_id = state.active_conversation_id.clone(); - let pending_followups = state.pending_followups.clone(); - let visible_followup = active_conversation_id - .as_ref() - .and_then(|id| pending_followups.get(id).cloned()); - let visible = active_conversation_id - .as_ref() - .and_then(|id| state.conversations.get(id)) - .cloned(); - let conversation_views = state - .conversations - .values() - .map(|conversation| { - let followup = pending_followups - .get(&conversation.conversation_id) - .cloned(); - conversation_view(conversation, followup) - }) - .collect(); - let workspaces = state - .workspaces - .iter() - .map(|path| { - let mut workspace_convos: Vec<&ConversationState> = state - .conversations - .values() - .filter(|conversation| &conversation.workspace_path == path) - .collect(); - // Newest chats first (most recently sent), drafts (updated_at 0) - // fall to the bottom. - workspace_convos.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); - let conversations = workspace_convos - .into_iter() - .map(|conversation| ConversationSessionSummaryDto { - conversation_id: conversation.conversation_id.clone(), - title: conversation.title.clone(), - updated_at: (conversation.updated_at > 0) - .then(|| conversation.updated_at.to_string()), - is_draft: conversation.messages.is_empty(), - is_running: !conversation.active_request_ids.is_empty(), - has_pending_followup: pending_followups - .contains_key(&conversation.conversation_id), - }) - .collect(); - let (kind, display_name) = registrations - .get(path) - .cloned() - .unwrap_or((WorkspaceKindDto::Project, None)); - let selected_conversation_id = selected_workspace_conversation_id(&state, path); - WorkspaceSessionDto { - kind, - workspace_path: path.clone(), - workspace_name: display_name.unwrap_or_else(|| workspace_name(path)), - configured: true, - configuration_error: None, - selected_conversation_id, - conversations, - } - }) - .collect(); - let saved_workspaces = self - .projects - .list_saved_workspaces() - .unwrap_or_default() - .into_iter() - .map(|record| SavedWorkspaceSummaryDto { - id: record.id, - name: record.name, - updated_at: record.updated_at, - }) - .collect(); - Ok(SessionSnapshotDto { - active_workspace_path: state.active_workspace_path.clone(), - active_conversation_id, - visible_messages: visible - .as_ref() - .map(|c| c.messages.clone()) - .unwrap_or_default(), - visible_active_request_ids: visible - .as_ref() - .map(|c| c.active_request_ids.clone()) - .unwrap_or_default(), - visible_request_agent_ids: visible - .as_ref() - .map(|c| c.request_agent_ids.clone()) - .unwrap_or_default(), - visible_todos: visible - .as_ref() - .map(|c| c.todos.clone()) - .unwrap_or_default(), - visible_goal: visible.as_ref().and_then(|c| c.goal.clone()), - visible_followup, - conversation_views, - ui_error: None, - workspaces, - saved_workspaces, - }) - } -} - -/// Shared Tauri desktop state. -pub struct DesktopState { - pub manager: Arc, - pub terminal_manager: Arc, -} - -impl DesktopState { - /// Creates desktop state for the copied GUI. - pub fn new(emitter: Arc, projects: Arc) -> Self { - Self { - manager: Arc::new(RuntimeManager::new(emitter.clone(), projects)), - terminal_manager: Arc::new(TerminalManager::new(emitter)), - } - } -} - -pub(crate) fn canonicalize_workspace_path(path: PathBuf) -> Result { - Ok(path - .canonicalize() - .with_context(|| format!("Failed to open workspace {}", path.display()))? - .to_string_lossy() - .into_owned()) -} - -pub(crate) fn format_error_chain(error: &anyhow::Error) -> String { - error - .chain() - .map(ToString::to_string) - .collect::>() - .join(": ") -} - -fn is_provider_auth_error_message(message: &str) -> bool { - let lower = message.to_ascii_lowercase(); - lower.contains("invalid_api_key") - || lower.contains("unauthorized") - || (lower.contains("api key") - && (lower.contains("invalid") - || lower.contains("expired") - || lower.contains("incorrect"))) - || (lower.contains("token") && lower.contains("expired")) - || (lower.contains("authentication") - && (lower.contains("failed") || lower.contains("invalid"))) -} - -fn provider_auth_error_message(raw: &str) -> String { - format!( - "Provider authentication failed before the turn could run. The selected API key/login was rejected or expired. Re-open Settings → Providers and reconnect the provider, then retry.\n\nThe GUI dropped the live graff session so the next retry starts fresh.\n\n{raw}" - ) -} - -/// Runs a literal substring search over the workspace. Tries ripgrep first -/// (fast, .gitignore-aware), then `git grep`. Returns up to `limit` matches. -fn workspace_text_search( - workspace_path: &str, - query: &str, - ends_with: Option<&[String]>, - starts_with: Option<&str>, - limit: usize, -) -> Vec { - let mut command = Command::new("rg"); - command.args([ - "--line-number", - "--no-heading", - "--color", - "never", - "--fixed-strings", - "--smart-case", - "--max-count", - "20", - ]); - if let Some(exts) = ends_with { - for ext in exts { - command - .arg("--glob") - .arg(format!("*.{}", ext.trim_start_matches('.'))); - } - } - command - .arg("--") - .arg(query) - .arg(".") - .current_dir(workspace_path); - - let stdout = match command.output() { - Ok(output) => String::from_utf8_lossy(&output.stdout).into_owned(), - // ripgrep absent: fall back to git grep (needs a git repo). - Err(_) => { - let output = Command::new("git") - .args([ - "grep", - "--line-number", - "--fixed-strings", - "-I", - "-e", - query, - ]) - .current_dir(workspace_path) - .output(); - match output { - Ok(output) => String::from_utf8_lossy(&output.stdout).into_owned(), - Err(_) => return vec![], - } - } - }; - - parse_grep_results(&stdout, starts_with, limit) -} - -/// Parses `path:line:content` lines (ripgrep / git grep) into search results. -fn parse_grep_results( - text: &str, - starts_with: Option<&str>, - limit: usize, -) -> Vec { - text.lines() - .filter_map(|line| { - let mut parts = line.splitn(3, ':'); - let path = parts.next()?; - let line_no: u32 = parts.next()?.parse().ok()?; - let content = parts.next()?; - if let Some(prefix) = starts_with { - let normalized = path.trim_start_matches("./"); - if !normalized.starts_with(prefix) { - return None; - } - } - Some(WorkspaceSearchResultDto { - node_id: format!("{path}:{line_no}"), - kind: "match".into(), - path: Some(path.trim_start_matches("./").to_string()), - start_line: Some(line_no), - end_line: Some(line_no), - preview: content.trim().chars().take(200).collect(), - relevance: None, - distance: None, - }) - }) - .take(limit) - .collect() -} - -/// On-disk form of a conversation (transient request state is not persisted). -#[derive(serde::Serialize, serde::Deserialize)] -struct PersistedConversation { - workspace_path: String, - conversation_id: String, - title: String, - messages: Vec, - active_agent_id: Option, - #[serde(default)] - plan_mode: bool, - #[serde(default)] - ultracode_enabled: bool, - #[serde(default)] - goal: Option, - #[serde(default)] - updated_at: i64, -} - -type ModelUsageByProvider = HashMap>; - -#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] -struct PersistedModelUsage { - #[serde(default)] - prompt_count: u64, - #[serde(default)] - last_used_at: i64, -} - -/// Path to the transcript store (`~/.codegraff-gui/conversations.json`). -#[derive(serde::Serialize, serde::Deserialize, Default)] -struct PersistedPromptSettings { - #[serde(default)] - selected_provider: Option, - #[serde(default)] - selected_model: Option, - #[serde(default)] - selected_effort: Option, - #[serde(default)] - fast_enabled: bool, - #[serde(default)] - model_usage: ModelUsageByProvider, -} - -/// Path to the persisted prompt selection (`~/.codegraff-gui/prompt-settings.json`). -fn prompt_settings_store_path() -> Option { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".codegraff-gui") - .join("prompt-settings.json") - }) -} - -/// Loads the last model/effort/fast selection so it survives app restarts. -fn load_persisted_prompt_settings() -> PersistedPromptSettings { - let Some(path) = prompt_settings_store_path() else { - return PersistedPromptSettings::default(); - }; - let Ok(data) = std::fs::read_to_string(&path) else { - return PersistedPromptSettings::default(); - }; - serde_json::from_str(&data).unwrap_or_default() -} - -fn save_prompt_settings(settings: &PersistedPromptSettings) { - let Some(path) = prompt_settings_store_path() else { - return; - }; - if let Some(dir) = path.parent() { - let _ = std::fs::create_dir_all(dir); - } - if let Ok(data) = serde_json::to_string_pretty(settings) { - let _ = std::fs::write(&path, data); - } -} - -fn conversations_store_path() -> Option { - std::env::var_os("HOME").map(|home| { - PathBuf::from(home) - .join(".codegraff-gui") - .join("conversations.json") - }) -} - -/// Loads persisted transcripts so past chats survive a restart. Continuing a -/// chat now spawns `graff --json --resume `, so new/updated -/// chats also regain engine-side model context when their session file exists. -fn load_persisted_conversations() -> HashMap { - let Some(path) = conversations_store_path() else { - return HashMap::new(); - }; - let Ok(text) = std::fs::read_to_string(&path) else { - return HashMap::new(); - }; - let Ok(items) = serde_json::from_str::>(&text) else { - return HashMap::new(); - }; - items - .into_iter() - .map(|conversation| { - let title = recover_conversation_title(&conversation.title, &conversation.messages); - ( - conversation.conversation_id.clone(), - ConversationState { - workspace_path: conversation.workspace_path, - conversation_id: conversation.conversation_id, - title, - messages: conversation.messages, - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id: conversation.active_agent_id, - plan_mode: conversation.plan_mode, - ultracode_enabled: conversation.ultracode_enabled, - todos: Vec::new(), - goal: conversation.goal, - updated_at: conversation.updated_at, - }, - ) - }) - .collect() -} - -fn recover_conversation_title(title: &str, messages: &[SessionMessageDto]) -> String { - if !is_placeholder_title(title) { - return title.to_string(); - } - - first_user_message_text(messages) - .map(title_from_prompt) - .unwrap_or_else(|| title_from_prompt("")) -} - -fn first_user_message_text(messages: &[SessionMessageDto]) -> Option<&str> { - messages.iter().find_map(|message| match message { - SessionMessageDto::User { text, .. } if !text.trim().is_empty() => Some(text.as_str()), - _ => None, - }) -} - -fn set_active_workspace(state: &mut RuntimeState, workspace_path: &str) { - state.active_workspace_path = Some(workspace_path.into()); - if !state.workspaces.iter().any(|path| path == workspace_path) { - state.workspaces.insert(0, workspace_path.into()); - } -} - -fn normalize_runtime_selection(state: &mut RuntimeState) { - let conversations = &state.conversations; - state - .selected_by_workspace - .retain(|workspace_path, conversation_id| { - conversations - .get(conversation_id) - .is_some_and(|conversation| &conversation.workspace_path == workspace_path) - }); - - if let Some(active_conversation_id) = state.active_conversation_id.clone() { - let active_workspace_path = state.active_workspace_path.clone(); - let is_valid_active = state - .conversations - .get(&active_conversation_id) - .is_some_and(|conversation| { - active_workspace_path - .as_ref() - .is_none_or(|path| &conversation.workspace_path == path) - }); - if !is_valid_active { - state.active_conversation_id = active_workspace_path - .as_deref() - .and_then(|workspace_path| first_workspace_conversation_id(state, workspace_path)); - } - } -} - -fn selected_workspace_conversation_id( - state: &RuntimeState, - workspace_path: &str, -) -> Option { - state - .selected_by_workspace - .get(workspace_path) - .filter(|conversation_id| { - state - .conversations - .get(*conversation_id) - .is_some_and(|conversation| conversation.workspace_path == workspace_path) - }) - .cloned() -} - -fn first_workspace_conversation_id(state: &RuntimeState, workspace_path: &str) -> Option { - state - .conversations - .values() - .filter(|conversation| conversation.workspace_path == workspace_path) - .max_by(|a, b| { - a.updated_at - .cmp(&b.updated_at) - .then_with(|| a.conversation_id.cmp(&b.conversation_id)) - }) - .map(|conversation| conversation.conversation_id.clone()) -} - -fn conversation_view( - conversation: &ConversationState, - followup: Option, -) -> ConversationViewSnapshotDto { - ConversationViewSnapshotDto { - workspace_path: conversation.workspace_path.clone(), - conversation_id: conversation.conversation_id.clone(), - messages: conversation.messages.clone(), - active_request_ids: conversation.active_request_ids.clone(), - request_agent_ids: conversation.request_agent_ids.clone(), - todos: conversation.todos.clone(), - goal: conversation.goal.clone(), - followup, - } -} - -fn command(name: &str, usage: &str, requires_workspace: bool) -> CommandDescriptorDto { - CommandDescriptorDto { - name: name.into(), - usage: usage.into(), - aliases: vec![], - kind: CommandKindDto::Builtin, - value: None, - is_agent_switch: false, - execution_kind: CommandExecutionKindDto::Runnable, - requires_workspace, - requires_conversation: matches!(name, "compact" | "goal" | "loop" | "ultracode"), - argument_hint: None, - result_kind: CommandResultKindDto::Text, - } -} - -/// macOS GUI processes launched from Finder/Dock get launchd's minimal -/// environment, not the user's shell — so `export OPENAI_API_KEY=…` (and any -/// other `*_API_KEY`) from .zshrc/.zprofile is invisible to children we spawn. -/// Capture the login shell's environment once (best-effort, time-bounded) so a -/// GUI-spawned `graff` sees the same provider keys the terminal `graff` does. -fn login_shell_env() -> &'static std::collections::HashMap { - static ENV: std::sync::OnceLock> = - std::sync::OnceLock::new(); - ENV.get_or_init(|| { - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()); - let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - // -il = interactive login shell: sources the login profile AND - // .zshrc/.bashrc, where most users export their *_API_KEY. - let out = std::process::Command::new(&shell) - .args(["-ilc", "env"]) - .output(); - let _ = tx.send(out); - }); - let mut map = std::collections::HashMap::new(); - if let Ok(Ok(out)) = rx.recv_timeout(std::time::Duration::from_secs(4)) { - if out.status.success() { - for line in String::from_utf8_lossy(&out.stdout).lines() { - if let Some((k, v)) = line.split_once('=') { - map.insert(k.to_string(), v.to_string()); - } - } - } - } - map - }) -} - -fn bash_command() -> CommandDescriptorDto { - CommandDescriptorDto { - argument_hint: Some("".into()), - ..command("bash", "Run a shell command in the workspace.", true) - } -} - -/// Spawns a persistent `graff --json` child for a conversation. `--yolo` skips -/// permission prompts (the GUI has no approval surface yet); stderr is discarded -/// since the protocol carries errors as `error` events on stdout. -fn spawn_graff_session( - workspace_path: &str, - model: Option<&str>, - conversation_id: &str, -) -> Result { - let bin = codegraff_binary(); - let mut command = tokio::process::Command::new(&bin); - command - .arg("--json") - .arg("--yolo") - .arg("--resume") - .arg(conversation_id); - if let Some(model) = model.filter(|model| !model.is_empty() && *model != "default") { - command.arg("--model").arg(model); - } - // macOS GUI apps (Finder/Dock) inherit launchd's minimal env, not the user's - // shell — so OPENAI_API_KEY & co. exported in .zshrc are invisible to graff, - // which then silently falls back to a file-based login (e.g. an empty - // codegraff account). Pass the login shell's env so the GUI sees the same - // provider keys the terminal does. - command.envs(login_shell_env()); - let mut child = command - .current_dir(workspace_path) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::null()) - .spawn() - .with_context(|| { - format!( - "Failed to launch the codegraff engine ('{bin}'). Install the CLI (curl -fsSL https://codegraff.com/install.sh | sh) or set CODEGRAFF_GUI_BINARY to the graff binary path." - ) - })?; - let stdin = child.stdin.take().context("graff session missing stdin")?; - let stdout = child - .stdout - .take() - .context("graff session missing stdout")?; - let reader = BufReader::new(stdout).lines(); - Ok(GraffSession { - child, - stdin: Arc::new(Mutex::new(stdin)), - reader: Some(reader), - }) -} - -/// Reads the model catalog from `graff --schema`. Returns empty on any failure -/// (missing binary, parse error) so callers fall back to the CLI default. -async fn codegraff_schema_models() -> Vec { - let output = match tokio::process::Command::new(codegraff_binary()) - .arg("--schema") - .output() - .await - { - Ok(output) if output.status.success() => output.stdout, - _ => return vec![], - }; - let schema: serde_json::Value = match serde_json::from_slice(&output) { - Ok(schema) => schema, - Err(_) => return vec![], - }; - let provider_names: HashMap = schema - .get("providers") - .and_then(serde_json::Value::as_array) - .map(|providers| { - providers - .iter() - .filter_map(|provider| { - Some(( - provider.get("id")?.as_str()?.to_string(), - provider - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or(provider.get("id")?.as_str()?) - .to_string(), - )) - }) - .collect() - }) - .unwrap_or_default(); - let provider_defaults: HashMap = schema - .get("providers") - .and_then(serde_json::Value::as_array) - .map(|providers| { - providers - .iter() - .filter_map(|provider| { - Some(( - provider.get("id")?.as_str()?.to_string(), - provider.get("default_model")?.as_str()?.to_string(), - )) - }) - .collect() - }) - .unwrap_or_default(); - - schema - .get("models") - .and_then(serde_json::Value::as_array) - .map(|models| { - models - .iter() - .filter_map(|model| { - let provider = model.get("provider")?.as_str()?.to_string(); - let name = model.get("name")?.as_str()?.to_string(); - Some(ModelOption { - provider_name: provider_names.get(&provider).cloned(), - is_default: provider_defaults - .get(&provider) - .is_some_and(|default_model| default_model == &name), - provider, - name, - context: model.get("context").and_then(serde_json::Value::as_u64), - }) - }) - .collect() - }) - .unwrap_or_default() -} - -/// Reads and parses the workspace `.mcp.json`, or None if absent/invalid. -fn read_mcp_config(workspace_path: &str) -> Option { - let path = Path::new(workspace_path).join(".mcp.json"); - let text = std::fs::read_to_string(path).ok()?; - serde_json::from_str(&text).ok() -} - -/// Writes the workspace `.mcp.json` pretty-printed. -fn write_mcp_config(workspace_path: &str, config: &serde_json::Value) -> Result<()> { - let path = Path::new(workspace_path).join(".mcp.json"); - let text = serde_json::to_string_pretty(config).context("Failed to serialize .mcp.json")?; - std::fs::write(path, text).context("Failed to write .mcp.json") -} - -/// Builds server summaries from the workspace `.mcp.json`. Tool counts are 0: -/// MCP tools are loaded inside the `graff` session and aren't enumerable here. -fn read_mcp_servers(workspace_path: &str) -> Vec { - let config = match read_mcp_config(workspace_path) { - Some(config) => config, - None => return vec![], - }; - let servers = match config - .get("mcpServers") - .and_then(serde_json::Value::as_object) - { - Some(servers) => servers, - None => return vec![], - }; - servers - .iter() - .map(|(name, cfg)| { - let url = cfg.get("url").and_then(serde_json::Value::as_str); - let command = cfg.get("command").and_then(serde_json::Value::as_str); - let args = cfg - .get("args") - .and_then(serde_json::Value::as_array) - .map(|args| { - args.iter() - .filter_map(serde_json::Value::as_str) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); - let (server_type, target) = match url { - Some(url) => ("http".to_string(), url.to_string()), - None => { - let target = match command { - Some(command) if args.is_empty() => command.to_string(), - Some(command) => format!("{command} {args}"), - None => String::new(), - }; - ("stdio".to_string(), target) - } - }; - McpServerSummaryDto { - name: name.clone(), - server_type, - target, - is_disabled: cfg - .get("disabled") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false), - tool_count: 0, - tools: vec![], - error: None, - auth_status: None, - } - }) - .collect() -} - -/// Human-readable provider name, reusing the adapter's provider table. -fn provider_display_name(provider_id: &str) -> String { - fallback_providers() - .iter() - .find(|provider| provider.id == provider_id) - .map(|provider| provider.name.to_string()) - .unwrap_or_else(|| provider_id.to_string()) -} - -fn filtered_catalog_models<'a>( - catalog: &'a [ModelOption], - configured_provider_ids: &HashSet, -) -> Vec<&'a ModelOption> { - catalog - .iter() - .filter(|model| configured_provider_ids.contains(&model.provider)) - .collect() -} - -fn prompt_model_option(model: &ModelOption) -> PromptModelOptionDto { - // Effort applies exactly per the binary's providerTakesEffort: a grok-* model - // never takes effort (any provider); otherwise codex/codegraff/deepseek/kimi - // do. (No gpt- carve-out - the gateway honors reasoning_effort for gpt-* too; - // and grok must be excluded even on the codegraff provider.) - let effort_capable = if model.name.starts_with("grok") { - false - } else { - matches!( - model.provider.as_str(), - "codex" | "codegraff" | "deepseek" | "kimi" - ) - }; - let reasoning_efforts: Vec = if effort_capable { - vec!["low".into(), "medium".into(), "high".into()] - } else { - vec![] - }; - - PromptModelOptionDto { - provider_id: model.provider.clone(), - provider_name: model - .provider_name - .clone() - .unwrap_or_else(|| provider_display_name(&model.provider)), - model_id: model.name.clone(), - model_name: Some(model.name.clone()), - context_length: model.context, - supports_reasoning: !reasoning_efforts.is_empty(), - reasoning_efforts, - } -} - -fn selected_prompt_model_pair( - catalog: &[ModelOption], - configured_provider_ids: &HashSet, - selected_provider: Option<&str>, - selected_model: Option<&str>, -) -> Option<(String, String)> { - let available_catalog = filtered_catalog_models(catalog, configured_provider_ids); - - // No codegraff-first bias: mirror the harness, which defaults to the first - // configured provider in spec/schema order and routes bare model names - // prefer-direct. Forcing codegraff here biased users onto the metered - // gateway even when a cheaper direct provider was keyed. - let default_model = available_catalog - .iter() - .find(|model| model.is_default) - .copied() - .or_else(|| available_catalog.first().copied()); - - selected_provider - .zip(selected_model) - .and_then(|(provider, model)| { - available_catalog - .iter() - .find(|candidate| candidate.provider == provider && candidate.name == model) - .map(|candidate| (candidate.provider.clone(), candidate.name.clone())) - }) - .or_else(|| default_model.map(|model| (model.provider.clone(), model.name.clone()))) -} - -fn prompt_model_arg(_provider: &str, model: &str) -> String { - // Pass the BARE model name and let the harness route it to a provider via the - // same prefer-direct resolution the TUI uses (`/model `). Prefixing the - // provider ("kimi kimi-k2.7" or "kimi/kimi-k2.7") is NOT understood by - // `--model`: it falls back to the codegraff gateway, which then rejects the - // unknown model. The harness picks the right provider for a bare model name - // (kimi-k2.7 -> kimi when keyed; only falling back to codegraff if no direct - // provider is configured), so the GUI must call it exactly like the terminal. - model.to_string() -} - -fn selected_pair_exists( - catalog: &[ModelOption], - configured_provider_ids: &HashSet, - selected_provider: Option<&str>, - selected_model: Option<&str>, -) -> bool { - let Some((provider, model)) = selected_provider.zip(selected_model) else { - return false; - }; - filtered_catalog_models(catalog, configured_provider_ids) - .iter() - .any(|candidate| candidate.provider == provider && candidate.name == model) -} - -fn increment_model_usage( - usage: &mut ModelUsageByProvider, - provider_id: &str, - model_id: &str, - now: i64, -) { - let entry = usage - .entry(provider_id.to_string()) - .or_default() - .entry(model_id.to_string()) - .or_default(); - entry.prompt_count = entry.prompt_count.saturating_add(1); - entry.last_used_at = now; -} - -fn model_usage_for<'a>( - usage: &'a ModelUsageByProvider, - provider_id: &str, - model_id: &str, -) -> Option<&'a PersistedModelUsage> { - usage - .get(provider_id) - .and_then(|models| models.get(model_id)) -} - -fn model_sort_name(model: &ModelOption) -> String { - format!( - "{}\u{0}{}\u{0}{}", - model.name.to_lowercase(), - model - .provider_name - .as_deref() - .unwrap_or(&model.provider) - .to_lowercase(), - model.provider.to_lowercase() - ) -} - -fn usage_ranked_catalog_models<'a>( - catalog: &'a [ModelOption], - configured_provider_ids: &HashSet, - model_usage: &ModelUsageByProvider, -) -> Vec<&'a ModelOption> { - let mut models = filtered_catalog_models(catalog, configured_provider_ids); - models.sort_by(|left, right| { - let left_count = model_usage_for(model_usage, &left.provider, &left.name) - .map(|usage| usage.prompt_count) - .unwrap_or(0); - let right_count = model_usage_for(model_usage, &right.provider, &right.name) - .map(|usage| usage.prompt_count) - .unwrap_or(0); - - right_count - .cmp(&left_count) - .then_with(|| model_sort_name(left).cmp(&model_sort_name(right))) - }); - models -} - -fn build_prompt_settings_from_catalog( - catalog: &[ModelOption], - configured_provider_ids: &HashSet, - selected_provider: Option<&str>, - selected_model: Option<&str>, - selected_effort: Option, - fast_enabled: bool, - model_usage: &ModelUsageByProvider, -) -> PromptSettingsDto { - if catalog.is_empty() { - if configured_provider_ids.contains("codegraff") { - return PromptSettingsDto { - available_models: vec![PromptModelOptionDto { - provider_id: "codegraff".into(), - provider_name: "Codegraff".into(), - model_id: "default".into(), - model_name: Some("Default".into()), - context_length: None, - supports_reasoning: false, - reasoning_efforts: vec![], - }], - selected_provider_id: Some("codegraff".into()), - selected_model_id: Some("default".into()), - selected_reasoning_effort: None, - fast_enabled, - // codegraff is the `chat` kind, not `responses`: /fast is a no-op. - fast_applies: false, - }; - } - - return PromptSettingsDto { - available_models: vec![], - selected_provider_id: None, - selected_model_id: None, - selected_reasoning_effort: None, - fast_enabled, - fast_applies: false, - }; - } - - let available_catalog = - usage_ranked_catalog_models(catalog, configured_provider_ids, model_usage); - let available_models = available_catalog - .iter() - .map(|model| prompt_model_option(model)) - .collect(); - - let selected_pair = selected_prompt_model_pair( - catalog, - configured_provider_ids, - selected_provider, - selected_model, - ); - let selected_effort = if selected_pair_exists( - catalog, - configured_provider_ids, - selected_provider, - selected_model, - ) { - selected_effort - } else { - None - }; - - // /fast only changes behavior on the codex provider (the harness `responses` - // kind, which emits `service_tier:"priority"`); it reports `applies:false` - // for every other provider. Gate on the *resolved* provider so the GUI - // matches what the harness will actually do for the active selection. - let fast_applies = selected_pair - .as_ref() - .map(|(provider, _)| provider == "codex") - .unwrap_or(false); - - PromptSettingsDto { - available_models, - selected_provider_id: selected_pair.as_ref().map(|(provider, _)| provider.clone()), - selected_model_id: selected_pair.as_ref().map(|(_, model)| model.clone()), - selected_reasoning_effort: selected_pair.and(selected_effort), - fast_enabled, - fast_applies, - } -} - -/// Writes one newline-delimited protocol request to a session's stdin. Locks -/// the shared stdin handle internally so a turn write and an `ask_user` answer -/// can't interleave on the pipe. -async fn write_session_line( - stdin: &Mutex, - line: &str, -) -> std::io::Result<()> { - let mut stdin = stdin.lock().await; - stdin.write_all(line.as_bytes()).await?; - stdin.write_all(b"\n").await?; - stdin.flush().await -} - -/// A short, single-line summary of a tool result for the collapsed UI row. -fn tool_result_summary(text: &str) -> Option { - let first_line = text.lines().find(|line| !line.trim().is_empty())?.trim(); - if first_line.is_empty() { - return None; - } - const LIMIT: usize = 160; - if first_line.chars().count() > LIMIT { - Some(format!( - "{}…", - first_line.chars().take(LIMIT).collect::() - )) - } else { - Some(first_line.to_string()) - } -} - -/// Parses the `todos` array from a `todo_write` tool call's input into typed -/// items. The harness sends `{"todos":[{"content":string,"status":string}]}` -/// with replace-the-whole-list semantics and no ids, so a stable id is derived -/// from the item's index. Unknown/missing statuses default to `Pending`. -fn parse_todo_items(input: Option<&serde_json::Value>) -> Vec { - input - .and_then(|value| value.get("todos")) - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .enumerate() - .map(|(index, item)| { - let content = item - .get("content") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - .to_string(); - let status = match item.get("status").and_then(serde_json::Value::as_str) { - Some("in_progress") => SessionTodoStatusDto::InProgress, - Some("completed") => SessionTodoStatusDto::Completed, - Some("cancelled") => SessionTodoStatusDto::Cancelled, - _ => SessionTodoStatusDto::Pending, - }; - SessionTodoDto { - id: format!("todo-{index:04}"), - content, - status, - } - }) - .collect() - }) - .unwrap_or_default() -} - -/// Maps a `tool_call` event's name + input JSON to a typed UI detail. Known -/// built-ins get rich rows; everything else (incl. MCP tools) falls back to -/// `Unknown`, which renders the tool name. -fn tool_call_detail(name: &str, input: Option<&serde_json::Value>) -> ToolCallDetailDto { - let str_field = |key: &str| -> Option { - input - .and_then(|value| value.get(key)) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }; - match name { - "bash" => ToolCallDetailDto::Shell { - command: str_field("command").unwrap_or_default(), - cwd: None, - description: None, - }, - "read_file" => ToolCallDetailDto::FileRead { - path: str_field("path").unwrap_or_default(), - start_line: None, - end_line: None, - }, - "write_file" => ToolCallDetailDto::FileUpdate { - path: str_field("path").unwrap_or_default(), - operation: FileOperationDto::Overwrite, - }, - "edit_file" => ToolCallDetailDto::FileUpdate { - path: str_field("path").unwrap_or_default(), - operation: FileOperationDto::Replace, - }, - "subagent" => ToolCallDetailDto::Task { - agent_id: str_field("agent").unwrap_or_default(), - label: str_field("description").unwrap_or_else(|| "subagent".to_string()), - }, - // Todos render in the dedicated task-progress dock, not the activity - // feed; the GUI hides these ops, but only when the detail kind matches. - "todo_write" => ToolCallDetailDto::TodoWrite { - count: input - .and_then(|value| value.get("todos")) - .and_then(serde_json::Value::as_array) - .map(Vec::len) - .unwrap_or(0), - }, - "todo_read" => ToolCallDetailDto::TodoRead, - "workflow" => ToolCallDetailDto::Task { - agent_id: String::new(), - label: "workflow".to_string(), - }, - // Surface codedb's query like a command chip (e.g. "codedb search foo") - // instead of a bare "Ran codedb", mirroring how bash renders. - "codedb" => ToolCallDetailDto::Shell { - command: format!("codedb {}", str_field("command").unwrap_or_default()) - .trim() - .to_string(), - cwd: None, - description: None, - }, - other => ToolCallDetailDto::Unknown { - name: other.to_string(), - }, - } -} - -/// Builds the interactive followup from an `ask_user` tool call's input. A -/// non-empty `options` array makes it a single-choice prompt; otherwise it's a -/// free-text prompt. -fn build_followup_request( - conversation_id: &str, - request_id: &str, - workspace_path: &str, - input: Option<&serde_json::Value>, - followup_id: Option, -) -> FollowupRequestDto { - let question = input - .and_then(|value| value.get("question")) - .and_then(serde_json::Value::as_str) - .unwrap_or("") - .to_string(); - let options = input - .and_then(|value| value.get("options")) - .and_then(serde_json::Value::as_array) - .map(|items| { - items - .iter() - .filter_map(serde_json::Value::as_str) - .enumerate() - .map(|(index, label)| FollowupOptionDto { - id: format!("option-{index}"), - label: label.to_string(), - }) - .collect::>() - }) - .filter(|options| !options.is_empty()); - let kind = if options.is_some() { - FollowupKind::Single - } else { - FollowupKind::Text - }; - FollowupRequestDto { - followup_id: followup_id.unwrap_or_else(|| Uuid::new_v4().to_string()), - workspace_path: workspace_path.to_string(), - conversation_id: conversation_id.to_string(), - request_id: request_id.to_string(), - kind, - question, - options, - } -} - -/// The single line written to graff's stdin in reply to an `ask_user` prompt. -/// New binaries expect structured answer JSON, preserving multiline text via -/// JSON escaping. Legacy followups keep the pre-protocol plain answer line. -fn followup_answer_line(request: &FollowupRequestDto, response: &FollowupResponseDto) -> String { - let answer = if response.cancelled { - String::new() - } else { - match request.kind { - FollowupKind::Text => response.text.clone().unwrap_or_default(), - FollowupKind::Single | FollowupKind::Multi => { - let selected = response.selected_option_ids.clone().unwrap_or_default(); - let options = request.options.clone().unwrap_or_default(); - selected - .iter() - .filter_map(|id| { - options - .iter() - .find(|option| &option.id == id) - .map(|option| option.label.clone()) - }) - .collect::>() - .join(", ") - } - } - }; - if request.followup_id.starts_with("legacy-") { - return answer.replace(['\n', '\r'], " "); - } - serde_json::json!({ - "type": "answer", - "text": answer, - "cancelled": response.cancelled, - "call_id": request.followup_id, - }) - .to_string() -} - -/// Resolves the `graff` binary to an ABSOLUTE path. Sessions spawn with a child -/// `current_dir` set to the workspace, and a relative program path would be -/// resolved against that workspace (not the repo) — so a relative override like -/// `CODEGRAFF_GUI_BINARY=../zig-out/bin/graff` must be canonicalized here. -/// True only for a real, non-empty, executable file. A path that exists but is -/// empty or not executable (e.g. an `externalBin` sidecar copy a `cargo build` -/// left half-written, or a clobbered 0-byte binary) must NOT be returned as the -/// engine path — a bare `is_file()` check would hand it back and shadow the -/// working fallbacks below, and the spawn then dies with EACCES on exec. -fn is_usable_binary(path: &Path) -> bool { - let Ok(meta) = std::fs::metadata(path) else { - return false; - }; - if !meta.is_file() || meta.len() == 0 { - return false; - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - meta.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - true - } -} - -fn codegraff_binary() -> String { - // 1. Explicit override, canonicalized to absolute when it resolves to a - // usable file. A broken override falls through to the built-in rather - // than failing the launch outright. - if let Some(value) = std::env::var("CODEGRAFF_GUI_BINARY") - .ok() - .filter(|value| !value.trim().is_empty()) - { - if let Ok(absolute) = std::fs::canonicalize(&value) { - if is_usable_binary(&absolute) { - return absolute.to_string_lossy().into_owned(); - } - } - // Doesn't resolve to a usable file — fall through to the built-in. - } - // 2. Bundled sidecar: Tauri's externalBin lands next to the app executable - // (Contents/MacOS/graff on macOS), so a packaged .app is self-contained — - // no separate CLI install needed. This is what fixes GUI-only users. - // Guard on is_usable_binary: an interrupted `cargo build` can leave a - // 0-byte sidecar here, and a bare is_file() check would return it and - // shadow the working binaries below (the EACCES-at-exec failure). - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let p = dir.join("graff"); - if is_usable_binary(&p) { - return p.to_string_lossy().into_owned(); - } - } - } - // 3. Dev build: /../../zig-out/bin/graff (only exists on a dev machine). - let candidate = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../zig-out/bin/graff"); - if let Ok(absolute) = std::fs::canonicalize(&candidate) { - if is_usable_binary(&absolute) { - return absolute.to_string_lossy().into_owned(); - } - } - // 4. Known install locations. A Finder/Dock-launched .app inherits launchd's - // minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin), which excludes ~/bin, - // /usr/local/bin and /opt/homebrew/bin — exactly where the CLI installer - // puts `graff`. So a bare PATH lookup (step 5) misses an installed binary - // and the session spawn fails with ENOENT. Probe the standard spots by - // absolute path first. - if let Ok(home) = std::env::var("HOME") { - for rel in ["bin/graff", ".local/bin/graff"] { - let p = Path::new(&home).join(rel); - if is_usable_binary(&p) { - return p.to_string_lossy().into_owned(); - } - } - } - for abs in [ - "/opt/homebrew/bin/graff", - "/usr/local/bin/graff", - "/usr/bin/graff", - ] { - if is_usable_binary(Path::new(abs)) { - return abs.to_string(); - } - } - // 5. Last resort: bare name, resolved against whatever PATH we inherited. - "graff".into() -} - -#[derive(Debug, Clone)] -struct CodegraffProvider { - id: String, - name: String, - env_key: Option, - auth_method: ProviderAuthMethodKindDto, -} - -/// Static fallback list, used only when `graff --schema` can't be read (e.g. -/// the binary is missing or too old). The live list comes from the harness — -/// see `schema_providers`. -fn fallback_providers() -> Vec { - use ProviderAuthMethodKindDto::*; - fn p( - id: &str, - name: &str, - env: Option<&str>, - auth: ProviderAuthMethodKindDto, - ) -> CodegraffProvider { - CodegraffProvider { - id: id.into(), - name: name.into(), - env_key: env.map(Into::into), - auth_method: auth, - } - } - vec![ - p( - "codegraff", - "Codegraff", - Some("CODEGRAFF_API_KEY"), - CodegraffDevice, - ), - p("anthropic", "Anthropic", Some("ANTHROPIC_API_KEY"), ApiKey), - p("deepseek", "DeepSeek", Some("DEEPSEEK_API_KEY"), ApiKey), - p("openai", "OpenAI", Some("OPENAI_API_KEY"), ApiKey), - p("minimax", "MiniMax", Some("MINIMAX_API_KEY"), ApiKey), - p("xiaomi", "Xiaomi", Some("XIAOMI_API_KEY"), ApiKey), - p("kimi", "Kimi", Some("KIMI_API_KEY"), ApiKey), - p("xai", "xAI", Some("XAI_API_KEY"), ApiKey), - p("zai", "Z.AI", Some("ZAI_API_KEY"), ApiKey), - p("codex", "Codex / ChatGPT", None, CodexDevice), - ] -} - -/// The provider list the settings page renders, derived live from the harness's -/// `graff --schema` so adding a provider in the Zig binary makes it appear here -/// automatically — names, env vars, and login method all come from the harness. -/// codegraff (primary login) is pinned first and codex last; otherwise harness -/// order is preserved. Falls back to `fallback_providers` if the schema can't -/// be read. -async fn schema_providers() -> Vec { - let output = match tokio::process::Command::new(codegraff_binary()) - .arg("--schema") - .output() - .await - { - Ok(output) if output.status.success() => output, - _ => return fallback_providers(), - }; - let Ok(schema) = serde_json::from_slice::(&output.stdout) else { - return fallback_providers(); - }; - let Some(entries) = schema.get("providers").and_then(|value| value.as_array()) else { - return fallback_providers(); - }; - let mut providers: Vec = entries - .iter() - .filter_map(|entry| { - let id = entry.get("id")?.as_str()?.to_string(); - let name = entry - .get("name") - .and_then(|value| value.as_str()) - .unwrap_or(&id) - .to_string(); - let auth_method = match entry.get("login").and_then(|value| value.as_str()) { - Some("codegraff_device") => ProviderAuthMethodKindDto::CodegraffDevice, - Some("codex_device") => ProviderAuthMethodKindDto::CodexDevice, - _ => ProviderAuthMethodKindDto::ApiKey, - }; - // Only api-key providers surface an env var; login providers carry a - // placeholder env_key in the schema that the UI must not show. - let env_key = if matches!(auth_method, ProviderAuthMethodKindDto::ApiKey) { - entry - .get("env_key") - .and_then(|value| value.as_str()) - .filter(|value| !value.is_empty()) - .map(|value| value.to_string()) - } else { - None - }; - Some(CodegraffProvider { - id, - name, - env_key, - auth_method, - }) - }) - .collect(); - if providers.is_empty() { - return fallback_providers(); - } - providers.sort_by_key(|provider| match provider.id.as_str() { - "codegraff" => 0u8, - "codex" => 2, - _ => 1, - }); - providers -} - -async fn list_codegraff_providers() -> Result> { - let key_list = codegraff_key_list().await.unwrap_or_default(); - Ok(schema_providers() - .await - .iter() - .map(|provider| provider_summary_with_key_list(provider, &key_list)) - .collect()) -} - -async fn configured_provider_ids() -> HashSet { - let key_list = codegraff_key_list().await.unwrap_or_default(); - configured_provider_ids_from_providers(&schema_providers().await, &key_list) -} - -fn configured_provider_ids_from_providers( - providers: &[CodegraffProvider], - key_list: &str, -) -> HashSet { - providers - .iter() - .filter(|provider| provider_configured(provider, key_list)) - .map(|provider| provider.id.clone()) - .collect() -} - -async fn provider_summary(provider_id: &str) -> Result { - let key_list = codegraff_key_list().await.unwrap_or_default(); - let providers = schema_providers().await; - let provider = providers - .iter() - .find(|provider| provider.id == provider_id) - .with_context(|| format!("Unknown provider {provider_id}"))?; - Ok(provider_summary_with_key_list(provider, &key_list)) -} - -fn provider_summary_with_key_list( - provider: &CodegraffProvider, - key_list: &str, -) -> ProviderSummaryDto { - let mut auth_methods = vec![ProviderAuthMethodDto { - kind: provider.auth_method.clone(), - label: provider_auth_label(provider), - }]; - // Kimi accepts both an API key and a device-code OAuth login (`graff login - // kimi`). Offer both so users can re-auth via the CLI's OAuth flow when - // their key is removed or expires — mirroring codegraff/codex. - if provider.id == "kimi" { - auth_methods.push(ProviderAuthMethodDto { - kind: ProviderAuthMethodKindDto::KimiDevice, - label: "Kimi Code device login".into(), - }); - } - ProviderSummaryDto { - id: provider.id.clone(), - name: provider.name.clone(), - configured: provider_configured(provider, key_list), - auth_methods, - env_override: provider_env_override(provider), - } -} - -fn validate_provider_auth_completion( - summary: &ProviderSummaryDto, - submitted_api_key: bool, -) -> Result<()> { - if submitted_api_key || summary.configured { - return Ok(()); - } - - anyhow::bail!( - "Provider login not detected yet. Complete the login flow in Terminal, then try Finish setup again." - ) -} - -/// Builds the env-override hint when a provider's `_API_KEY` is set, -/// locating where it's defined so the UI can offer to open that file. -fn provider_env_override(provider: &CodegraffProvider) -> Option { - let env_key = provider.env_key.as_deref()?; - // Same env the spawned session sees (process env + login_shell_env), so the - // override hint matches what the harness actually uses. - if !env_has_provider_key(env_key) { - return None; - } - let (file_path, line) = match find_env_definition(env_key) { - Some((path, line)) => (Some(path), Some(line)), - None => (None, None), - }; - Some(ProviderEnvOverrideDto { - env_key: env_key.to_string(), - file_path, - line, - }) -} - -/// Scans the user's shell startup files for where `env_key` is exported, -/// returning the file path and 1-based line number of the first definition. -fn find_env_definition(env_key: &str) -> Option<(String, u32)> { - let home = std::env::var_os("HOME").map(PathBuf::from)?; - let candidates = [ - ".zshenv", - ".zprofile", - ".zshrc", - ".profile", - ".bashrc", - ".bash_profile", - ".config/fish/config.fish", - ]; - for relative in candidates { - let path = home.join(relative); - let Ok(text) = std::fs::read_to_string(&path) else { - continue; - }; - for (index, raw_line) in text.lines().enumerate() { - let line = raw_line.trim_start(); - if line.starts_with('#') { - continue; - } - let defines = line.contains(&format!("{env_key}=")) - || line.contains(&format!("set -x {env_key} ")) - || line.contains(&format!("set -gx {env_key} ")); - if defines { - return Some((path.to_string_lossy().into_owned(), (index + 1) as u32)); - } - } - } - None -} - -fn provider_auth_label(provider: &CodegraffProvider) -> String { - match provider.auth_method { - ProviderAuthMethodKindDto::ApiKey => provider - .env_key - .as_deref() - .map(|env_key| format!("API key ({env_key} or graff key set {})", provider.id)) - .unwrap_or_else(|| "API key".into()), - ProviderAuthMethodKindDto::CodegraffDevice => "Codegraff device login".into(), - ProviderAuthMethodKindDto::CodexDevice => "Codex browser login".into(), - ProviderAuthMethodKindDto::KimiDevice => "Kimi Code device login".into(), - _ => "Unsupported".into(), - } -} - -/// True if `env_key` has a non-empty value in either the GUI process env OR the -/// login-shell env the spawned harness session inherits (`login_shell_env`). On a -/// Finder/Dock launch the GUI's own env is minimal, but the session is spawned -/// with `command.envs(login_shell_env())`, so a provider keyed only via a shell -/// export (e.g. KIMI_API_KEY in .zshrc) must still count as configured here - -/// otherwise its models vanish from the picker yet the session can run them. -fn env_has_provider_key(env_key: &str) -> bool { - if std::env::var(env_key) - .map(|value| !value.trim().is_empty()) - .unwrap_or(false) - { - return true; - } - login_shell_env() - .iter() - .any(|(key, value)| key.as_str() == env_key && !value.trim().is_empty()) -} - -fn provider_configured(provider: &CodegraffProvider, key_list: &str) -> bool { - if provider - .env_key - .as_deref() - .is_some_and(env_has_provider_key) - { - return true; - } - - let home = std::env::var_os("HOME").map(PathBuf::from); - provider_login_configured(provider, key_list, home.as_deref()) -} - -fn provider_login_configured( - provider: &CodegraffProvider, - key_list: &str, - home: Option<&Path>, -) -> bool { - match provider.id.as_str() { - "codegraff" => { - home_file_exists(home, ".simple-harness-codegraff.json") - || key_list_mentions_provider(key_list, &provider.id) - } - "codex" => home_codex_auth_has_valid_token(home), - "kimi" => { - key_list_mentions_provider(key_list, &provider.id) || kimi_auth_has_valid_token(home) - } - _ => key_list_mentions_provider(key_list, &provider.id), - } -} - -fn key_list_mentions_provider(key_list: &str, provider_id: &str) -> bool { - // `graff key list` lists every provider; a provider only counts as keyed - // when its row's trailing "stored" column isn't the "—" placeholder. - key_list.lines().any(|line| { - let mut parts = line.split_whitespace(); - parts.next() == Some(provider_id) - && parts - .last() - .map_or(false, |stored| stored != "—" && stored != "-") - }) -} - -fn home_file_exists(home: Option<&Path>, relative_path: &str) -> bool { - home.map(|home| home.join(relative_path).exists()) - .unwrap_or(false) -} - -fn home_codex_auth_has_valid_token(home: Option<&Path>) -> bool { - home.map(|home| codex_auth_file_has_valid_token(&home.join(".codex/auth.json"))) - .unwrap_or(false) -} - -/// Kimi OAuth login (`graff login kimi`) writes a JSON token file to -/// `~/.kimi/credentials/graff-oauth.json`. Treat an expired token file as not -/// configured so the GUI asks the user to reconnect before starting a run. -fn kimi_auth_has_valid_token(home: Option<&Path>) -> bool { - home.map(|home| { - let path = home.join(".kimi/credentials/graff-oauth.json"); - let Ok(text) = std::fs::read_to_string(&path) else { - return false; - }; - let Ok(value) = serde_json::from_str::(&text) else { - return false; - }; - if value - .get("expires_at") - .and_then(serde_json::Value::as_i64) - .is_some_and(|expires_at| unix_seconds() >= expires_at.saturating_sub(60)) - { - return false; - } - value - .get("access_token") - .and_then(|token| token.as_str()) - .map(|token| !token.trim().is_empty() && jwt_token_not_expired(token)) - .unwrap_or(false) - }) - .unwrap_or(false) -} - -fn codex_auth_file_has_valid_token(path: &Path) -> bool { - let Ok(text) = std::fs::read_to_string(path) else { - return false; - }; - codex_auth_text_has_valid_token(&text) -} - -fn codex_auth_text_has_valid_token(text: &str) -> bool { - let Ok(value) = serde_json::from_str::(text) else { - return false; - }; - value - .get("tokens") - .and_then(|tokens| tokens.get("access_token")) - .or_else(|| value.get("access_token")) - .and_then(|token| token.as_str()) - .map(|token| !token.trim().is_empty() && jwt_token_not_expired(token)) - .unwrap_or(false) -} - -fn jwt_token_not_expired(token: &str) -> bool { - let Some(payload) = token.split('.').nth(1) else { - // API keys and opaque OAuth tokens do not expose an expiry locally; they - // still need the runtime auth-error handling above. - return true; - }; - let Ok(bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload) else { - return true; - }; - let Ok(value) = serde_json::from_slice::(&bytes) else { - return true; - }; - let Some(exp) = value.get("exp").and_then(serde_json::Value::as_i64) else { - return true; - }; - unix_seconds() < exp.saturating_sub(60) -} - -fn unix_seconds() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_secs() as i64) - .unwrap_or(0) -} - -fn provider_env_key(provider_id: &str) -> Option { - fallback_providers() - .into_iter() - .find(|provider| provider.id == provider_id) - .and_then(|provider| provider.env_key) -} - -fn auth_session_id(provider_id: &str) -> String { - format!("codegraff-provider:{provider_id}") -} - -fn provider_from_auth_session_id(auth_session_id: &str) -> &str { - auth_session_id - .strip_prefix("codegraff-provider:") - .unwrap_or(auth_session_id) -} - -fn cli_login_session(provider_id: &str, message: &str) -> ProviderAuthSessionDto { - ProviderAuthSessionDto { - kind: ProviderAuthSessionKindDto::CliLogin, - auth_session_id: auth_session_id(provider_id), - requires_api_key: false, - api_key_hint: Some(message.into()), - url_parameters: vec![], - verification_uri: None, - verification_uri_complete: None, - user_code: None, - expires_in_seconds: None, - authorization_url: None, - } -} - -async fn codegraff_key_list() -> Result { - let output = tokio::process::Command::new(codegraff_binary()) - .args(["key", "list"]) - .output() - .await - .context("Failed to run graff key list")?; - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) - } else { - anyhow::bail!(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } -} - -/// Deletes a provider's stored credential everywhere the CLI persists it: -/// macOS Keychain (service `simple-harness`, account=provider), the -/// `~/.simple-harness-keys.json` fallback, and the codegraff/codex login files. -/// Missing entries are not an error (removal is idempotent). -fn remove_stored_credential(provider_id: &str) -> Result<()> { - let home = std::env::var_os("HOME").map(PathBuf::from); - - // macOS Keychain entry written by `graff key set`. - #[cfg(target_os = "macos")] - { - let _ = Command::new("security") - .args([ - "delete-generic-password", - "-s", - "simple-harness", - "-a", - provider_id, - ]) - .output(); - } - - // The 0600 JSON fallback store (used off macOS; cleaned everywhere to be safe). - if let Some(home) = &home { - let path = home.join(".simple-harness-keys.json"); - if let Ok(text) = std::fs::read_to_string(&path) { - if let Ok(mut value) = serde_json::from_str::(&text) { - let removed = value - .as_object_mut() - .map(|object| object.remove(provider_id).is_some()) - .unwrap_or(false); - if removed { - let text = - serde_json::to_string(&value).context("Failed to serialize key store")?; - std::fs::write(&path, text).context("Failed to update key store")?; - } - } - } - } - - // Provider-specific login files. - if let Some(home) = &home { - match provider_id { - "codegraff" => { - let _ = std::fs::remove_file(home.join(".simple-harness-codegraff.json")); - } - "codex" => { - let _ = std::fs::remove_file(home.join(".codex/auth.json")); - } - "kimi" => { - let _ = std::fs::remove_file(home.join(".kimi/credentials/graff-oauth.json")); - } - _ => {} - } - } - - Ok(()) -} - -async fn run_codegraff_key_set(provider_id: &str, api_key: &str) -> Result<()> { - let output = tokio::process::Command::new(codegraff_binary()) - .args(["key", "set", provider_id, api_key]) - .output() - .await - .with_context(|| format!("Failed to store {provider_id} API key with graff key set"))?; - if output.status.success() { - Ok(()) - } else { - anyhow::bail!(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } -} - -async fn launch_codegraff_login(login_target: Option<&str>) -> Result<()> { - let binary = codegraff_binary(); - #[cfg(target_os = "macos")] - { - // `open -a Terminal --args login` passes the args to Terminal.app, - // not to graff, and opens the binary as a document (a bare REPL). Instead - // write a tiny executable launcher and open it with Terminal: Terminal - // runs an executable `.command` file, so the full `graff login [codex]` - // command line reaches graff — and unlike AppleScript `do script`, this - // needs no Apple Events / Automation permission. - let mut command_line = shell_single_quote(&binary); - command_line.push_str(" login"); - if let Some(target) = login_target { - command_line.push(' '); - command_line.push_str(&shell_single_quote(target)); - } - let script_path = - std::env::temp_dir().join(format!("graff-login-{}.command", Uuid::new_v4().simple())); - let script = format!("#!/bin/sh\n{command_line}\nrm -f \"$0\"\n"); - std::fs::write(&script_path, script) - .context("Failed to write graff login launcher script")?; - std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o700)) - .context("Failed to mark graff login launcher executable")?; - tokio::process::Command::new("open") - .args(["-a", "Terminal"]) - .arg(&script_path) - .spawn() - .context("Failed to launch Terminal for graff login")?; - Ok(()) - } - - #[cfg(not(target_os = "macos"))] - { - let mut command = tokio::process::Command::new(binary); - command.arg("login"); - if let Some(target) = login_target { - command.arg(target); - } - command.spawn().context("Failed to launch graff login")?; - Ok(()) - } -} - -/// Wraps `value` in single quotes for a POSIX shell, escaping embedded quotes. -#[cfg(target_os = "macos")] -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\\''")) -} - -/// Normalizes a model-generated title: first line, stripped of quotes/punctuation, -/// capped to a handful of words. -fn clean_title(raw: &str) -> String { - let first = raw.trim().lines().next().unwrap_or("").trim(); - let trimmed = first - .trim_matches(|c: char| c == '"' || c == '\'' || c == '.' || c == ':' || c.is_whitespace()); - trimmed - .split_whitespace() - .take(8) - .collect::>() - .join(" ") - .chars() - .take(60) - .collect() -} - -fn now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) -} - -fn title_from_prompt(prompt: &str) -> String { - let title = prompt - .split_whitespace() - .take(8) - .collect::>() - .join(" "); - if title.is_empty() { - "New chat".into() - } else { - title - } -} - -fn is_placeholder_title(title: &str) -> bool { - let title = title.trim(); - title.is_empty() || title.eq_ignore_ascii_case("New chat") -} - -fn run_shell_command(workspace_path: &str, shell_command: &str) -> Result { - let output = Command::new("/bin/sh") - .args(["-c", shell_command]) - .current_dir(workspace_path) - .output() - .with_context(|| format!("Failed to run shell command in {workspace_path}"))?; - Ok(format_command_output(&output)) -} - -fn format_command_output(output: &std::process::Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let mut text = String::new(); - if !stdout.is_empty() { - text.push_str(&stdout); - } - if !stderr.is_empty() { - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - text.push_str("[stderr]\n"); - text.push_str(&stderr); - } - match output.status.code() { - Some(0) => {} - Some(code) => { - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - text.push_str(&format!("[exit code {code}]")); - } - None => { - if !text.is_empty() && !text.ends_with('\n') { - text.push('\n'); - } - text.push_str("[terminated abnormally]"); - } - } - if text.is_empty() { - "(no output)".into() - } else { - text - } -} - -fn git_status_files(workspace_path: &str) -> Result> { - let output = Command::new("git") - .args(["status", "--short"]) - .current_dir(workspace_path) - .output()?; - if !output.status.success() { - return Ok(vec![]); - } - Ok(String::from_utf8_lossy(&output.stdout) - .lines() - .map(|line| WorkspaceFileStatusDto { - status: line.get(..2).unwrap_or("??").trim().into(), - path: line.get(3..).unwrap_or(line).into(), - }) - .collect()) -} - -fn run_git(workspace_path: &str, args: &[&str]) -> Result<()> { - let output = Command::new("git") - .args(args) - .current_dir(workspace_path) - .output()?; - if output.status.success() { - Ok(()) - } else { - anyhow::bail!(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } -} - -fn workspace_name(path: &str) -> String { - Path::new(path) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.into()) -} - -fn saved_detail( - id: String, - name: String, - layout_json: String, - updated_at: i64, -) -> SavedWorkspaceDetailDto { - SavedWorkspaceDetailDto { - id, - name, - layout_json, - updated_at, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[cfg(unix)] - fn mk_output(stdout: &[u8], stderr: &[u8], raw_status: i32) -> std::process::Output { - use std::os::unix::process::ExitStatusExt; - std::process::Output { - status: std::process::ExitStatus::from_raw(raw_status), - stdout: stdout.to_vec(), - stderr: stderr.to_vec(), - } - } - - #[test] - #[cfg(unix)] - fn format_command_output_empty_is_no_output() { - // stdout+stderr empty, exit 0 -> the "(no output)" placeholder. - assert_eq!( - format_command_output(&mk_output(b"", b"", 0)), - "(no output)" - ); - } - - #[test] - #[cfg(unix)] - fn format_command_output_stdout_only_unchanged() { - assert_eq!( - format_command_output(&mk_output(b"hello\n", b"", 0)), - "hello\n" - ); - } - - #[test] - #[cfg(unix)] - fn format_command_output_stderr_only_gets_label() { - // No stdout, so no leading newline; "[stderr]" label prefixes stderr. - assert_eq!( - format_command_output(&mk_output(b"", b"oops\n", 0)), - "[stderr]\noops\n" - ); - } - - #[test] - #[cfg(unix)] - fn format_command_output_stderr_separator_and_nonzero_exit() { - // stdout present -> "[stderr]" goes on its own line after a separator. - let out = format_command_output(&mk_output(b"out\n", b"err\n", 1 << 8)); - assert!(out.contains("out\n[stderr]\nerr\n")); - assert!(out.ends_with("[exit code 1]")); - } - - #[test] - #[cfg(unix)] - fn format_command_output_signal_terminated_is_abnormal() { - // raw status 2 -> WIFSIGNALED -> status.code() is None. - assert!(format_command_output(&mk_output(b"", b"", 2)).contains("[terminated abnormally]")); - } - - fn followup( - id: &str, - kind: FollowupKind, - options: Option>, - ) -> FollowupRequestDto { - FollowupRequestDto { - followup_id: id.to_string(), - workspace_path: "/tmp/work".to_string(), - conversation_id: "chat-1".to_string(), - request_id: "request-1".to_string(), - kind, - question: "Continue?".to_string(), - options, - } - } - - #[test] - fn structured_followup_answer_preserves_multiline_text() { - let request = followup("call-1", FollowupKind::Text, None); - let response = FollowupResponseDto { - followup_id: "call-1".to_string(), - cancelled: false, - text: Some("line 1\nline 2".to_string()), - selected_option_ids: None, - }; - let line = followup_answer_line(&request, &response); - let json: serde_json::Value = serde_json::from_str(&line).expect("answer JSON"); - assert_eq!(json["type"], "answer"); - assert_eq!(json["text"], "line 1\nline 2"); - assert_eq!(json["cancelled"], false); - assert_eq!(json["call_id"], "call-1"); - } - - #[test] - fn structured_followup_answer_serializes_cancel() { - let request = followup("call-2", FollowupKind::Text, None); - let response = FollowupResponseDto { - followup_id: "call-2".to_string(), - cancelled: true, - text: Some("ignored".to_string()), - selected_option_ids: None, - }; - let line = followup_answer_line(&request, &response); - let json: serde_json::Value = serde_json::from_str(&line).expect("answer JSON"); - assert_eq!(json["type"], "answer"); - assert_eq!(json["text"], ""); - assert_eq!(json["cancelled"], true); - assert_eq!(json["call_id"], "call-2"); - } - - #[test] - fn legacy_followup_answer_flattens_choice_labels() { - let request = followup( - "legacy-1", - FollowupKind::Single, - Some(vec![FollowupOptionDto { - id: "option-0".to_string(), - label: "yes\nplease".to_string(), - }]), - ); - let response = FollowupResponseDto { - followup_id: "legacy-1".to_string(), - cancelled: false, - text: None, - selected_option_ids: Some(vec!["option-0".to_string()]), - }; - assert_eq!(followup_answer_line(&request, &response), "yes please"); - } - - #[test] - fn placeholder_title_detection_accepts_empty_and_new_chat() { - assert!(is_placeholder_title("")); - assert!(is_placeholder_title(" New chat ")); - assert!(is_placeholder_title("new CHAT")); - assert!(!is_placeholder_title("Investigate stale sidebar state")); - } - - #[test] - fn recover_conversation_title_uses_first_user_message() { - let messages = vec![ - SessionMessageDto::Assistant { - id: "assistant-1".into(), - request_id: "request-1".into(), - text: "Hello".into(), - }, - SessionMessageDto::User { - id: "user-1".into(), - request_id: "request-1".into(), - text: "Fix the new chat title regression with deterministic fallback".into(), - }, - ]; - - assert_eq!( - recover_conversation_title("New chat", &messages), - "Fix the new chat title regression with deterministic" - ); - assert_eq!( - recover_conversation_title("Custom title", &messages), - "Custom title" - ); - } - - #[test] - fn selected_workspace_conversation_ignores_stale_ids() { - let mut state = RuntimeState::default(); - state.conversations.insert( - "chat-1".into(), - ConversationState { - workspace_path: "/tmp/work".into(), - conversation_id: "chat-1".into(), - title: "Existing".into(), - messages: vec![], - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id: None, - plan_mode: false, - ultracode_enabled: false, - todos: Vec::new(), - goal: None, - updated_at: 10, - }, - ); - state - .selected_by_workspace - .insert("/tmp/work".into(), "missing".into()); - - assert_eq!( - selected_workspace_conversation_id(&state, "/tmp/work"), - None - ); - - state - .selected_by_workspace - .insert("/tmp/work".into(), "chat-1".into()); - assert_eq!( - selected_workspace_conversation_id(&state, "/tmp/work"), - Some("chat-1".into()) - ); - } - - #[test] - fn provider_configured_detects_codegraff_login_file() { - let temp_dir = std::env::temp_dir().join(format!( - "codegraff-provider-test-{}", - Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&temp_dir).expect("create temp dir"); - std::fs::write(temp_dir.join(".simple-harness-codegraff.json"), "{}") - .expect("write login file"); - - let configured = provider_login_configured( - &CodegraffProvider { - id: "codegraff".into(), - name: "Codegraff".into(), - env_key: None, - auth_method: ProviderAuthMethodKindDto::CodegraffDevice, - }, - "", - Some(&temp_dir), - ); - let _ = std::fs::remove_dir_all(temp_dir); - - assert!(configured); - } - - #[test] - fn codex_auth_text_requires_nonempty_access_token() { - assert!(codex_auth_text_has_valid_token( - r#"{"tokens":{"access_token":"token"}}"# - )); - assert!(codex_auth_text_has_valid_token( - r#"{"access_token":"legacy-token"}"# - )); - assert!(!codex_auth_text_has_valid_token( - r#"{"tokens":{"access_token":" "}}"# - )); - assert!(!codex_auth_text_has_valid_token("{}")); - assert!(!codex_auth_text_has_valid_token("not json")); - } - - #[test] - fn codex_auth_text_rejects_expired_jwt_token() { - let expired_payload = - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"exp":1}"#); - let token = format!("header.{expired_payload}.signature"); - let auth = serde_json::json!({ "tokens": { "access_token": token } }).to_string(); - - assert!(!codex_auth_text_has_valid_token(&auth)); - } - - #[test] - fn provider_auth_error_detection_matches_invalid_api_key() { - assert!(is_provider_auth_error_message( - "api error: The API Key appears to be invalid or may have expired. Please verify your credentials and try again." - )); - assert!(provider_auth_error_message("invalid_api_key").contains("reconnect")); - } - - #[test] - fn provider_configured_detects_codex_auth_file_token() { - let temp_dir = - std::env::temp_dir().join(format!("codex-provider-test-{}", Uuid::new_v4().simple())); - let codex_dir = temp_dir.join(".codex"); - std::fs::create_dir_all(&codex_dir).expect("create codex dir"); - std::fs::write( - codex_dir.join("auth.json"), - r#"{"tokens":{"access_token":"token"}}"#, - ) - .expect("write codex auth file"); - - let configured = provider_login_configured( - &CodegraffProvider { - id: "codex".into(), - name: "Codex / ChatGPT".into(), - env_key: None, - auth_method: ProviderAuthMethodKindDto::CodexDevice, - }, - "", - Some(&temp_dir), - ); - let _ = std::fs::remove_dir_all(temp_dir); - - assert!(configured); - } - - fn provider_summary_for_test(configured: bool) -> ProviderSummaryDto { - ProviderSummaryDto { - id: "codegraff".into(), - name: "Codegraff".into(), - configured, - auth_methods: vec![ProviderAuthMethodDto { - kind: ProviderAuthMethodKindDto::CodegraffDevice, - label: "Codegraff device login".into(), - }], - env_override: None, - } - } - - #[test] - fn provider_completion_rejects_unfinished_cli_login() { - let error = validate_provider_auth_completion(&provider_summary_for_test(false), false) - .expect_err("unfinished login should be rejected"); - - assert!( - error - .to_string() - .contains("Provider login not detected yet") - ); - } - - #[test] - fn provider_completion_accepts_configured_cli_login() { - validate_provider_auth_completion(&provider_summary_for_test(true), false) - .expect("configured login should complete"); - } - - #[test] - fn provider_completion_allows_api_key_completion_to_report_summary() { - validate_provider_auth_completion(&provider_summary_for_test(false), true) - .expect("api-key completion is validated by graff key set"); - } - - fn model(provider: &str, name: &str) -> ModelOption { - ModelOption { - provider: provider.into(), - provider_name: None, - name: name.into(), - context: None, - is_default: provider == "codegraff" && name == "deepseek-v4-pro", - } - } - - fn provider_ids(ids: &[&str]) -> HashSet { - ids.iter().map(|id| (*id).to_string()).collect() - } - - #[test] - fn prompt_model_arg_uses_bare_model_name() { - // The harness only recognizes bare model names (routed to a provider by - // prefer-direct, like the TUI's `/model`); a "provider model" prefix is - // not understood by --model and falls back to the codegraff gateway. - assert_eq!(prompt_model_arg("codex", "gpt-5.5"), "gpt-5.5"); - assert_eq!(prompt_model_arg("kimi", "kimi-k2.7"), "kimi-k2.7"); - } - - #[test] - fn prompt_settings_returns_no_models_without_configured_providers() { - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "deepseek-v4-pro"), - model("codex", "gpt-5.5"), - ], - &HashSet::new(), - None, - None, - Some("high".into()), - true, - &HashMap::new(), - ); - - assert!(settings.available_models.is_empty()); - assert_eq!(settings.selected_provider_id, None); - assert_eq!(settings.selected_model_id, None); - assert_eq!(settings.selected_reasoning_effort, None); - assert!(settings.fast_enabled); - } - - #[test] - fn prompt_settings_filters_models_to_configured_providers() { - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "deepseek-v4-pro"), - model("codex", "gpt-5.5"), - model("openai", "gpt-4.1"), - ], - &provider_ids(&["codex"]), - None, - None, - None, - false, - &HashMap::new(), - ); - - assert_eq!(settings.available_models.len(), 1); - assert_eq!(settings.available_models[0].provider_id, "codex"); - assert_eq!(settings.available_models[0].model_id, "gpt-5.5"); - assert_eq!(settings.selected_provider_id.as_deref(), Some("codex")); - assert_eq!(settings.selected_model_id.as_deref(), Some("gpt-5.5")); - } - - #[test] - fn prompt_settings_ignores_persisted_unconfigured_model() { - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "deepseek-v4-pro"), - model("codex", "gpt-5.5"), - ], - &provider_ids(&["codegraff"]), - Some("codex"), - Some("gpt-5.5"), - Some("medium".into()), - false, - &HashMap::new(), - ); - - assert_eq!(settings.available_models.len(), 1); - assert_eq!(settings.selected_provider_id.as_deref(), Some("codegraff")); - assert_eq!( - settings.selected_model_id.as_deref(), - Some("deepseek-v4-pro") - ); - assert_eq!(settings.selected_reasoning_effort, None); - } - - fn usage_counts(entries: &[(&str, &str, u64)]) -> ModelUsageByProvider { - let mut usage = HashMap::new(); - for (provider, model, count) in entries { - usage - .entry((*provider).to_string()) - .or_insert_with(HashMap::new) - .insert( - (*model).to_string(), - PersistedModelUsage { - prompt_count: *count, - last_used_at: 0, - }, - ); - } - usage - } - - #[test] - fn prompt_settings_orders_models_alphabetically_without_usage() { - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "zeta"), - model("codegraff", "alpha"), - model("codex", "beta"), - ], - &provider_ids(&["codegraff", "codex"]), - None, - None, - None, - false, - &HashMap::new(), - ); - - let ordered: Vec<_> = settings - .available_models - .iter() - .map(|model| model.model_id.as_str()) - .collect(); - assert_eq!(ordered, vec!["alpha", "beta", "zeta"]); - } - - #[test] - fn prompt_settings_ranks_models_by_user_usage_then_alphabetically() { - let usage = usage_counts(&[ - ("codegraff", "zeta", 2), - ("codex", "beta", 5), - ("openai", "removed", 99), - ]); - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "zeta"), - model("codegraff", "alpha"), - model("codex", "beta"), - model("codex", "gamma"), - ], - &provider_ids(&["codegraff", "codex"]), - None, - None, - None, - false, - &usage, - ); - - let ordered: Vec<_> = settings - .available_models - .iter() - .map(|model| model.model_id.as_str()) - .collect(); - assert_eq!(ordered, vec!["beta", "zeta", "alpha", "gamma"]); - } - - #[test] - fn prompt_settings_usage_ranking_does_not_change_default_selection() { - let usage = usage_counts(&[("codex", "popular", 100)]); - let settings = build_prompt_settings_from_catalog( - &[ - model("codegraff", "deepseek-v4-pro"), - model("codex", "popular"), - ], - &provider_ids(&["codegraff", "codex"]), - None, - None, - None, - false, - &usage, - ); - - assert_eq!(settings.available_models[0].provider_id, "codex"); - assert_eq!(settings.available_models[0].model_id, "popular"); - assert_eq!(settings.selected_provider_id.as_deref(), Some("codegraff")); - assert_eq!( - settings.selected_model_id.as_deref(), - Some("deepseek-v4-pro") - ); - } - - #[test] - fn increment_model_usage_updates_count_and_timestamp() { - let mut usage = ModelUsageByProvider::new(); - increment_model_usage(&mut usage, "codex", "gpt-5", 10); - increment_model_usage(&mut usage, "codex", "gpt-5", 20); - - let entry = model_usage_for(&usage, "codex", "gpt-5").expect("usage entry"); - assert_eq!(entry.prompt_count, 2); - assert_eq!(entry.last_used_at, 20); - } - - #[test] - fn selected_prompt_model_pair_falls_back_from_unavailable_codex_model() { - let pair = selected_prompt_model_pair( - &[ - model("codegraff", "deepseek-v4-pro"), - model("codex", "gpt-5.5"), - ], - &provider_ids(&["codegraff", "codex"]), - Some("codex"), - Some("gpt-5.5-codex"), - ); - - assert_eq!(pair, Some(("codegraff".into(), "deepseek-v4-pro".into()))); - } - - #[test] - fn prompt_settings_schema_fallback_requires_configured_codegraff() { - let unavailable = build_prompt_settings_from_catalog( - &[], - &HashSet::new(), - None, - None, - None, - false, - &HashMap::new(), - ); - assert!(unavailable.available_models.is_empty()); - - let available = build_prompt_settings_from_catalog( - &[], - &provider_ids(&["codegraff"]), - None, - None, - None, - true, - &HashMap::new(), - ); - assert_eq!(available.available_models.len(), 1); - assert_eq!(available.selected_provider_id.as_deref(), Some("codegraff")); - assert_eq!(available.selected_model_id.as_deref(), Some("default")); - assert!(available.fast_enabled); - } - - #[test] - fn normalize_runtime_selection_falls_back_to_existing_conversation() { - let mut state = RuntimeState { - active_workspace_path: Some("/tmp/work".into()), - active_conversation_id: Some("missing".into()), - ..RuntimeState::default() - }; - state.conversations.insert( - "chat-old".into(), - ConversationState { - workspace_path: "/tmp/work".into(), - conversation_id: "chat-old".into(), - title: "Old".into(), - messages: vec![], - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id: None, - plan_mode: false, - ultracode_enabled: false, - todos: Vec::new(), - goal: None, - updated_at: 10, - }, - ); - state.conversations.insert( - "chat-new".into(), - ConversationState { - workspace_path: "/tmp/work".into(), - conversation_id: "chat-new".into(), - title: "New".into(), - messages: vec![], - active_request_ids: vec![], - request_agent_ids: HashMap::new(), - queued_prompts: VecDeque::new(), - active_agent_id: None, - plan_mode: false, - ultracode_enabled: false, - todos: Vec::new(), - goal: None, - updated_at: 20, - }, - ); - state - .selected_by_workspace - .insert("/tmp/work".into(), "missing".into()); - - normalize_runtime_selection(&mut state); - - assert_eq!(state.active_conversation_id, Some("chat-new".into())); - assert_eq!(state.selected_by_workspace.get("/tmp/work"), None); - } -} diff --git a/gui/src-tauri/src/terminal.rs b/gui/src-tauri/src/terminal.rs deleted file mode 100644 index 21c5efe0..00000000 --- a/gui/src-tauri/src/terminal.rs +++ /dev/null @@ -1,337 +0,0 @@ -use std::collections::HashMap; -use std::io::{Read, Write}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex as StdMutex}; -use std::thread; - -use anyhow::{Context, Result, anyhow}; -use bstr::ByteSlice; -use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; -use tokio::sync::Mutex; - -use crate::bridge::emitter::UiEventEmitter; -use crate::dto::{ - TerminalCloseInput, TerminalErrorEventDto, TerminalExitEventDto, TerminalOpenInput, - TerminalOutputEventDto, TerminalResizeInput, TerminalSessionDto, TerminalWriteInput, -}; -use crate::runtime::canonicalize_workspace_path; - -const DEFAULT_COLS: u16 = 80; -const DEFAULT_ROWS: u16 = 24; - -pub struct TerminalManager { - emitter: Arc, - sessions: Mutex>>, -} - -impl TerminalManager { - pub fn new(emitter: Arc) -> Self { - Self { - emitter, - sessions: Mutex::new(HashMap::new()), - } - } - - pub async fn open(&self, input: TerminalOpenInput) -> Result { - let workspace_path = canonicalize_workspace_path(PathBuf::from(input.workspace_path))?; - let (cols, rows) = normalize_size(input.cols, input.rows); - - if let Some(existing) = self.get_session(&input.terminal_id).await { - if existing.has_exited()? { - self.remove_session(&input.terminal_id).await; - } else { - existing.resize(cols, rows)?; - return existing.snapshot(); - } - } - - let session = spawn_terminal_session( - self.emitter.clone(), - input.terminal_id, - workspace_path, - cols, - rows, - )?; - let snapshot = session.snapshot()?; - - let mut sessions = self.sessions.lock().await; - sessions.insert(session.terminal_id.clone(), session); - - Ok(snapshot) - } - - pub async fn write(&self, input: TerminalWriteInput) -> Result<()> { - let session = self - .get_session(&input.terminal_id) - .await - .ok_or_else(|| anyhow!("Terminal session {} is not open", input.terminal_id))?; - session.write(&input.data) - } - - pub async fn resize(&self, input: TerminalResizeInput) -> Result<()> { - let session = self - .get_session(&input.terminal_id) - .await - .ok_or_else(|| anyhow!("Terminal session {} is not open", input.terminal_id))?; - let (cols, rows) = normalize_size(input.cols, input.rows); - session.resize(cols, rows) - } - - pub async fn close(&self, input: TerminalCloseInput) -> Result<()> { - let session = self.remove_session(&input.terminal_id).await; - if let Some(session) = session { - session.kill()?; - } - - Ok(()) - } - - async fn get_session(&self, terminal_id: &str) -> Option> { - let sessions = self.sessions.lock().await; - sessions.get(terminal_id).cloned() - } - - async fn remove_session(&self, terminal_id: &str) -> Option> { - let mut sessions = self.sessions.lock().await; - sessions.remove(terminal_id) - } -} - -struct TerminalSession { - terminal_id: String, - workspace_path: String, - shell: String, - master: StdMutex>, - writer: StdMutex>, - killer: StdMutex>, - exit_status: StdMutex, -} - -impl TerminalSession { - fn snapshot(&self) -> Result { - let size = lock_mutex(&self.master, "terminal master")?.get_size()?; - Ok(TerminalSessionDto { - terminal_id: self.terminal_id.clone(), - workspace_path: self.workspace_path.clone(), - shell: self.shell.clone(), - cols: size.cols, - rows: size.rows, - }) - } - - fn write(&self, data: &str) -> Result<()> { - if self.has_exited()? { - return Err(anyhow!("Terminal session {} has exited", self.terminal_id)); - } - - let mut writer = lock_mutex(&self.writer, "terminal writer")?; - writer.write_all(data.as_bytes())?; - writer.flush()?; - Ok(()) - } - - fn resize(&self, cols: u16, rows: u16) -> Result<()> { - if self.has_exited()? { - return Ok(()); - } - - lock_mutex(&self.master, "terminal master")?.resize(PtySize { - cols, - rows, - pixel_width: 0, - pixel_height: 0, - })?; - Ok(()) - } - - fn kill(&self) -> Result<()> { - if self.has_exited()? { - return Ok(()); - } - - let mut killer = lock_mutex(&self.killer, "terminal killer")?; - match killer.kill() { - Ok(()) => Ok(()), - Err(error) => Err(error).with_context(|| { - format!("Failed to terminate terminal session {}", self.terminal_id) - }), - } - } - - fn has_exited(&self) -> Result { - Ok(*lock_mutex(&self.exit_status, "terminal exit status")?) - } - - fn mark_exited(&self) -> Result<()> { - let mut exit_status = lock_mutex(&self.exit_status, "terminal exit status")?; - *exit_status = true; - Ok(()) - } -} - -fn spawn_terminal_session( - emitter: Arc, - terminal_id: String, - workspace_path: String, - cols: u16, - rows: u16, -) -> Result> { - let pty_system = native_pty_system(); - let pair = pty_system - .openpty(PtySize { - cols, - rows, - pixel_width: 0, - pixel_height: 0, - }) - .with_context(|| format!("Failed to create PTY for {}", workspace_path))?; - - let (shell, command) = build_shell_command(&workspace_path); - let child = pair - .slave - .spawn_command(command) - .with_context(|| format!("Failed to spawn shell {}", shell))?; - let killer = child.clone_killer(); - let reader = pair.master.try_clone_reader()?; - let writer = pair.master.take_writer()?; - - drop(pair.slave); - - let session = Arc::new(TerminalSession { - terminal_id, - workspace_path, - shell, - master: StdMutex::new(pair.master), - writer: StdMutex::new(writer), - killer: StdMutex::new(killer), - exit_status: StdMutex::new(false), - }); - - spawn_reader_thread(emitter.clone(), session.clone(), reader)?; - spawn_wait_thread(emitter, session.clone(), child)?; - - Ok(session) -} - -fn spawn_reader_thread( - emitter: Arc, - session: Arc, - mut reader: Box, -) -> Result<()> { - let thread_name = format!("terminal-reader-{}", session.terminal_id); - thread::Builder::new() - .name(thread_name) - .spawn(move || { - let mut buffer = [0u8; 4096]; - loop { - match reader.read(&mut buffer) { - Ok(0) => break, - Ok(count) => { - let data = buffer[..count].as_bstr().to_string(); - if let Err(error) = emitter.emit_terminal_output(TerminalOutputEventDto { - terminal_id: session.terminal_id.clone(), - data, - }) { - log::warn!("Failed to emit terminal output: {error:#}"); - } - } - Err(error) => { - if session.has_exited().unwrap_or(false) { - break; - } - - if let Err(emit_error) = - emitter.emit_terminal_error(TerminalErrorEventDto { - terminal_id: session.terminal_id.clone(), - message: error.to_string(), - }) - { - log::warn!("Failed to emit terminal reader error: {emit_error:#}"); - } - break; - } - } - } - }) - .context("Failed to spawn terminal reader thread")?; - - Ok(()) -} - -fn spawn_wait_thread( - emitter: Arc, - session: Arc, - mut child: Box, -) -> Result<()> { - let thread_name = format!("terminal-wait-{}", session.terminal_id); - thread::Builder::new() - .name(thread_name) - .spawn(move || match child.wait() { - Ok(status) => { - let exit_code = Some(status.exit_code()); - let signal = status.signal().map(ToOwned::to_owned); - if let Err(error) = session.mark_exited() { - log::warn!("Failed to update terminal exit state: {error:#}"); - } - if let Err(error) = emitter.emit_terminal_exit(TerminalExitEventDto { - terminal_id: session.terminal_id.clone(), - exit_code, - signal, - }) { - log::warn!("Failed to emit terminal exit event: {error:#}"); - } - } - Err(error) => { - if let Err(mark_error) = session.mark_exited() { - log::warn!("Failed to update terminal exit state: {mark_error:#}"); - } - if let Err(emit_error) = emitter.emit_terminal_error(TerminalErrorEventDto { - terminal_id: session.terminal_id.clone(), - message: error.to_string(), - }) { - log::warn!("Failed to emit terminal wait error: {emit_error:#}"); - } - } - }) - .context("Failed to spawn terminal wait thread")?; - - Ok(()) -} - -fn build_shell_command(workspace_path: &str) -> (String, CommandBuilder) { - #[cfg(windows)] - let shell = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()); - #[cfg(not(windows))] - let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); - - let mut command = CommandBuilder::new(shell.clone()); - #[cfg(not(windows))] - { - if PathBuf::from(&shell) - .file_name() - .and_then(|name| name.to_str()) - == Some("zsh") - { - command.arg("-o"); - command.arg("NO_PROMPT_SP"); - command.arg("-o"); - command.arg("NO_PROMPT_CR"); - } - } - command.cwd(workspace_path); - command.env("TERM", "xterm-256color"); - command.env("COLORTERM", "truecolor"); - command.env("TERM_PROGRAM", "codegraff-gui"); - - (shell, command) -} - -fn lock_mutex<'a, T>(mutex: &'a StdMutex, label: &str) -> Result> { - mutex.lock().map_err(|_| anyhow!("Failed to lock {label}")) -} - -fn normalize_size(cols: u16, rows: u16) -> (u16, u16) { - let cols = if cols == 0 { DEFAULT_COLS } else { cols.max(2) }; - let rows = if rows == 0 { DEFAULT_ROWS } else { rows.max(1) }; - (cols, rows) -} diff --git a/gui/src-tauri/tauri.conf.json b/gui/src-tauri/tauri.conf.json deleted file mode 100644 index f3660408..00000000 --- a/gui/src-tauri/tauri.conf.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", - "productName": "Codegraff", - "version": "0.0.166", - "identifier": "dev.codegraff.gui", - "build": { - "frontendDist": "../dist", - "devUrl": "http://localhost:5173", - "beforeDevCommand": "bun run dev", - "beforeBuildCommand": "bun run build" - }, - "app": { - "macOSPrivateApi": true, - "windows": [ - { - "title": "Codegraff", - "width": 1280, - "height": 860, - "dragDropEnabled": true, - "resizable": true, - "fullscreen": false, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "trafficLightPosition": { - "x": 19, - "y": 20 - }, - "backgroundColor": "#16140f" - } - ], - "security": { - "csp": null - } - }, - "bundle": { - "active": true, - "createUpdaterArtifacts": true, - "externalBin": ["binaries/graff"], - "targets": "all", - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "macOS": { - "signingIdentity": "Developer ID Application: Rachit Pradhan (WWP9DLJ27P)", - "hardenedRuntime": true - } - }, - "plugins": { - "updater": { - "endpoints": [ - "https://github.com/justrach/codegraff/releases/latest/download/latest.json" - ], - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEM1RjYxOTJEOEJGQzgzNDUKUldSRmcveUxMUm4yeGJ3aDBQQTdkYU9aa2ppN2Z4dUlCcWxvbFhBbkNyeUFEaGRyNVB0K3c4dVQK" - } - } -} diff --git a/gui/src/app/App.tsx b/gui/src/app/App.tsx index 60fe69f3..0dc2e0a1 100644 --- a/gui/src/app/App.tsx +++ b/gui/src/app/App.tsx @@ -7,7 +7,6 @@ import { useState, } from "react"; import type { PanelImperativeHandle } from "react-resizable-panels"; -import { getCurrentWindow } from "@tauri-apps/api/window"; import { ArrowLeftIcon, LoaderCircle, @@ -32,12 +31,18 @@ import { } from "../components/ui/Resizable"; import { SidebarProvider, useSidebar } from "../components/ui/Sidebar"; import { TooltipProvider } from "../components/ui/Tooltip"; -import { useThemeStore } from "./themeStore"; +import { hydrateThemeSettings, useThemeStore } from "./themeStore"; import { SidebarVisibilityContext } from "./sidebarVisibilityContext"; import { useSessionStore } from "../hooks/useSession"; import { DragDropProvider } from "../hooks/useFileDrop"; import { SessionProvider } from "./SessionProvider"; import { SettingsNavigationProvider } from "./settingsNavigation"; +import { closeWindow, setWindowTitle } from "../services/desktop/client"; +import { + getUiActiveWorkspacePath, + getUiWorkspaceLabel, + getWorkspaceMetaStoreKey, +} from "./sessionStore"; import { DEFAULT_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH, @@ -126,7 +131,17 @@ function AppShell() { const isSessionBootstrapped = useSessionStore( (state) => state.isBootstrapped, ); - const [isFullscreen, setIsFullscreen] = useState(false); + const windowTitle = useSessionStore((state) => { + const workspacePath = getUiActiveWorkspacePath(state); + if (workspacePath == null) { + return "Codegraff"; + } + const runtimeStatus = + state.workspaceMetaByKey[getWorkspaceMetaStoreKey(workspacePath)] + ?.runtimeStatus ?? null; + return `${getUiWorkspaceLabel(workspacePath, runtimeStatus)} - Codegraff`; + }); + const isFullscreen = false; const [isDesktopSidebarVisible, setIsDesktopSidebarVisible] = useState(true); const [isSettingsViewOpen, setIsSettingsViewOpen] = useState(false); const [selectedSettingsSection, setSelectedSettingsSection] = @@ -161,6 +176,14 @@ function AppShell() { setSelectedSettingsSection("mcp"); setIsSettingsViewOpen(true); }, []); + + useEffect(() => { + void hydrateThemeSettings(); + }, []); + + useEffect(() => { + void setWindowTitle(windowTitle).catch(() => undefined); + }, [windowTitle]); const settingsNavigation = useMemo( () => ({ openProviderSettings: openProvidersSettings, @@ -169,47 +192,6 @@ function AppShell() { [openMcpSettings, openProvidersSettings], ); - useEffect(() => { - if (import.meta.env.VITE_CODEGRAFF_QA_MOCK === "1") { - return; - } - - const appWindow = getCurrentWindow(); - let isMounted = true; - let unlistenResize: (() => void) | null = null; - let unlistenFocus: (() => void) | null = null; - - const syncFullscreenState = () => { - void appWindow.isFullscreen().then((nextIsFullscreen) => { - if (isMounted) { - setIsFullscreen(nextIsFullscreen); - } - }); - }; - - syncFullscreenState(); - void appWindow.onResized(syncFullscreenState).then((unlisten) => { - if (isMounted) { - unlistenResize = unlisten; - return; - } - unlisten(); - }); - void appWindow.onFocusChanged(syncFullscreenState).then((unlisten) => { - if (isMounted) { - unlistenFocus = unlisten; - return; - } - unlisten(); - }); - - return () => { - isMounted = false; - unlistenResize?.(); - unlistenFocus?.(); - }; - }, []); - useLayoutEffect(() => { const panel = sidebarPanelRef.current; if (panel == null) { @@ -232,18 +214,22 @@ function AppShell() { }; }, []); - // Global Cmd/Ctrl+N → new chat. Reuses NewChatTrigger's openNewChat (captured - // in newChatRef) so the git-worktree choice dialog flows through identically. - // Skipped while a settings view or dialog is open or while the session is - // mid-bootstrap, and ignores key presses originating inside an input/textarea - // so it never fights typing — though Cmd+N is uncommon enough to be safe. + // Global app shortcuts. useEffect(() => { function handleKeyDown(event: KeyboardEvent) { - if (!(event.metaKey || event.ctrlKey) || event.key !== "n") { + if (!(event.metaKey || event.ctrlKey)) { return; } - event.preventDefault(); - newChatRef.current?.(); + const key = event.key.toLowerCase(); + if (key === "n") { + event.preventDefault(); + newChatRef.current?.(); + return; + } + if (key === "w") { + event.preventDefault(); + void closeWindow().catch(() => undefined); + } } window.addEventListener("keydown", handleKeyDown); diff --git a/gui/src/app/SessionProvider.test.tsx b/gui/src/app/SessionProvider.test.tsx index ac27e73e..ef3bc6c7 100644 --- a/gui/src/app/SessionProvider.test.tsx +++ b/gui/src/app/SessionProvider.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import { useContext } from "react"; import { renderToStaticMarkup } from "react-dom/server"; @@ -15,6 +15,7 @@ let getRuntimeStatusCallCount = 0; let createManagedChatCallCount = 0; let handoffChatCallCount = 0; let openWorkspaceCallCount = 0; +let getSavedWorkspaceCallCount = 0; let renameWorkspaceCallCount = 0; let renameSavedWorkspaceCallCount = 0; let selectConversationCallCount = 0; @@ -31,6 +32,7 @@ let handoffChatImpl: ( input: import("../services/desktop/types/contracts").HandoffChatInput, ) => Promise; let openWorkspaceImpl: (workspacePath: string) => Promise; +let getSavedWorkspaceImpl: (workspaceId: string) => Promise; let selectConversationImpl: ( workspacePath: string, conversationId: string, @@ -78,7 +80,7 @@ function createSnapshot( workspacePath, }, ], - savedWorkspaces: [{ id: "saved-1", name: "Workspace", updatedAt: 1n }], + savedWorkspaces: [{ id: "saved-1", name: "Workspace", updatedAt: 1 }], uiError: null, visibleActiveRequestIds: [], visibleFollowup: null, @@ -110,7 +112,7 @@ function createSnapshot( } const runtimeStatusFixture: RuntimeStatus = { - availableOpenTargets: ["cursor"], + availableOpenTargets: ["file-manager"], configurationError: null, configured: true, gitBranchName: "main", @@ -137,10 +139,19 @@ mock.module("../hooks/useSessionBootstrap", () => ({ mock.module("../services/desktop/client", () => ({ setFast: async () => {}, + saveAttachmentFile: async (input: { name: string }) => `/tmp/${input.name}`, savePastedImage: async () => "/tmp/pasted-image.png", + readClipboardText: async () => "", + writeClipboardText: async () => {}, + pickDirectory: async () => "/workspace/codegraff-gui", + drainPendingOpen: async () => null, imageThumbnail: async () => "data:image/jpeg;base64,", openExternalUrl: async () => {}, + openInTarget: async () => {}, + openPathDefault: async () => {}, + openPathForEdit: async () => {}, openPathInTarget: async () => {}, + setWindowTitle: async () => {}, ensureConversationView: async () => { ensureConversationViewCallCount += 1; return createSnapshot("/workspace/unused", "unused"); @@ -153,7 +164,12 @@ mock.module("../services/desktop/client", () => ({ getRuntimeStatusCallCount += 1; return runtimeStatusFixture; }, - renameWorkspace: async (workspacePath: string, displayName: string | null) => { + getSessionSnapshot: async () => + createSnapshot("/workspace/codegraff-gui", "chat-1"), + renameWorkspace: async ( + workspacePath: string, + displayName: string | null, + ) => { renameWorkspaceCallCount += 1; return await renameWorkspaceImpl(workspacePath, displayName); }, @@ -175,6 +191,10 @@ mock.module("../services/desktop/client", () => ({ openWorkspaceCallCount += 1; return await openWorkspaceImpl(workspacePath); }, + getSavedWorkspace: async (workspaceId: string) => { + getSavedWorkspaceCallCount += 1; + return await getSavedWorkspaceImpl(workspaceId); + }, selectConversation: async (workspacePath: string, conversationId: string) => { selectConversationCallCount += 1; return await selectConversationImpl(workspacePath, conversationId); @@ -199,6 +219,10 @@ const { SessionProvider } = await import("./SessionProvider"); const { getPromptDraftKey } = await import("./sessionSnapshot"); const { resetSessionStore, sessionStore } = await import("./sessionStore"); +afterAll(() => { + mock.restore(); +}); + describe("SessionProvider", () => { beforeEach(() => { ensureConversationViewCallCount = 0; @@ -207,6 +231,7 @@ describe("SessionProvider", () => { createManagedChatCallCount = 0; handoffChatCallCount = 0; openWorkspaceCallCount = 0; + getSavedWorkspaceCallCount = 0; renameWorkspaceCallCount = 0; renameSavedWorkspaceCallCount = 0; selectConversationCallCount = 0; @@ -292,6 +317,21 @@ describe("SessionProvider", () => { ); openWorkspaceImpl = async (workspacePath: string) => createSnapshot(workspacePath, "chat-1"); + getSavedWorkspaceImpl = async (workspaceId: string) => ({ + id: workspaceId, + name: "Workspace", + updatedAt: 1, + layoutJson: JSON.stringify({ + panels: { + first: { + params: { + workspacePath: "/workspace/codegraff-gui", + conversationId: "chat-1", + }, + }, + }, + }), + }); selectConversationImpl = async ( workspacePath: string, conversationId: string, @@ -324,7 +364,7 @@ describe("SessionProvider", () => { }); renameSavedWorkspaceImpl = async (workspaceId: string, name: string) => createSnapshot("/workspace/codegraff-gui", "chat-1", { - savedWorkspaces: [{ id: workspaceId, name, updatedAt: 2n }], + savedWorkspaces: [{ id: workspaceId, name, updatedAt: 2 }], }); resetSessionStore(); }); @@ -338,15 +378,13 @@ describe("SessionProvider", () => { kind: "saved-workspace", workspace: { id: "saved-1", - layoutJson: "{\"grid\":true}", + layoutJson: '{"grid":true}', name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -363,8 +401,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.selectConversation("/workspace/other", "chat-9"); @@ -452,7 +489,10 @@ describe("SessionProvider", () => { } const actions = capturedActions as SessionActionsContextValue; - const selectPromise = actions.selectConversation("/workspace/other", "chat-9"); + const selectPromise = actions.selectConversation( + "/workspace/other", + "chat-9", + ); expect(sessionStore.getState().selection).toEqual({ chat: { @@ -535,9 +575,7 @@ describe("SessionProvider", () => { kind: "single-chat", }); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -554,8 +592,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.stopPrompt(); @@ -575,18 +612,16 @@ describe("SessionProvider", () => { kind: "saved-workspace", workspace: { id: "saved-1", - layoutJson: "{\"grid\":true}", + layoutJson: '{"grid":true}', name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); const workspaceRequest = deferred(); openWorkspaceImpl = async () => await workspaceRequest.promise; - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -603,8 +638,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; const openProjectPromise = actions.openProject("/workspace/other"); expect(openWorkspaceCallCount).toBe(1); @@ -616,9 +650,9 @@ describe("SessionProvider", () => { kind: "saved-workspace", workspace: { id: "saved-1", - layoutJson: "{\"grid\":true}", + layoutJson: '{"grid":true}', name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); @@ -635,9 +669,7 @@ describe("SessionProvider", () => { }); test("starting a sidebar chat without a project creates a managed chat workspace", async () => { - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -654,8 +686,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.startNewChat(); @@ -720,9 +751,7 @@ describe("SessionProvider", () => { ], }); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -739,8 +768,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.startNewChat(); @@ -753,13 +781,13 @@ describe("SessionProvider", () => { }); test("handoffing a chat updates the active selection to the target workspace", async () => { - sessionStore.getState().applySessionSnapshot( - createSnapshot("/workspace/codegraff-gui", "chat-1"), - ); + sessionStore + .getState() + .applySessionSnapshot( + createSnapshot("/workspace/codegraff-gui", "chat-1"), + ); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -776,8 +804,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.handoffChat({ branchName: "feature/worktree", @@ -802,11 +829,11 @@ describe("SessionProvider", () => { throw new Error("Expected a prompt draft key"); } - sessionStore.getState().setPromptDraftValue(sourceDraftKey, "Investigate the bug"); + sessionStore + .getState() + .setPromptDraftValue(sourceDraftKey, "Investigate the bug"); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -823,8 +850,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.handoffChat({ branchName: "feature/draft-handoff", @@ -833,11 +859,17 @@ describe("SessionProvider", () => { target: "worktree", }); - const targetDraftKey = getPromptDraftKey("/workspace/codegraff-gui-worktree", null); + const targetDraftKey = getPromptDraftKey( + "/workspace/codegraff-gui-worktree", + null, + ); expect(targetDraftKey).not.toBeNull(); - expect(sessionStore.getState().promptDraftsByKey[sourceDraftKey]).toBeUndefined(); expect( - sessionStore.getState().promptDraftsByKey[targetDraftKey as string]?.value, + sessionStore.getState().promptDraftsByKey[sourceDraftKey], + ).toBeUndefined(); + expect( + sessionStore.getState().promptDraftsByKey[targetDraftKey as string] + ?.value, ).toBe("Investigate the bug"); }); @@ -923,9 +955,7 @@ describe("SessionProvider", () => { ], }); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -942,8 +972,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.submitPrompt(); @@ -1052,9 +1081,7 @@ describe("SessionProvider", () => { ], }); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -1071,8 +1098,7 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.submitPrompt(); @@ -1086,13 +1112,13 @@ describe("SessionProvider", () => { }); test("renaming a project updates the workspace label in session state", async () => { - sessionStore.getState().applySessionSnapshot( - createSnapshot("/workspace/codegraff-gui", "chat-1"), - ); + sessionStore + .getState() + .applySessionSnapshot( + createSnapshot("/workspace/codegraff-gui", "chat-1"), + ); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -1109,15 +1135,15 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.renameWorkspace("/workspace/codegraff-gui", "Renamed UI"); expect(renameWorkspaceCallCount).toBe(1); - expect(sessionStore.getState().workspacesByPath["/workspace/codegraff-gui"]?.workspaceName).toBe( - "Renamed UI", - ); + expect( + sessionStore.getState().workspacesByPath["/workspace/codegraff-gui"] + ?.workspaceName, + ).toBe("Renamed UI"); expect(sessionStore.getState().selection).toEqual({ chat: { conversationId: "chat-1", @@ -1127,6 +1153,69 @@ describe("SessionProvider", () => { }); }); + test("opening a saved workspace loads only the active panel first", async () => { + const layoutJson = JSON.stringify({ + panels: { + first: { + params: { + workspacePath: "/workspace/codegraff-gui", + conversationId: "chat-1", + }, + }, + second: { + params: { + workspacePath: "/workspace/codegraff-gui", + conversationId: "chat-2", + }, + }, + }, + }); + getSavedWorkspaceImpl = async (workspaceId: string) => ({ + id: workspaceId, + name: "Performance board", + updatedAt: 10, + layoutJson, + }); + + let capturedActions: SessionActionsContextValue | null = null; + + function CaptureActions() { + capturedActions = useContext(SessionActionsContext); + return null; + } + + renderToStaticMarkup( + + + , + ); + + if (capturedActions == null) { + throw new Error("Expected session actions to be available"); + } + + const actions = capturedActions as SessionActionsContextValue; + + await actions.openSavedWorkspace("saved-1"); + + expect(getSavedWorkspaceCallCount).toBe(1); + expect(ensureConversationViewCallCount).toBe(1); + expect(selectConversationCallCount).toBe(1); + expect(sessionStore.getState().selection).toEqual({ + kind: "saved-workspace", + workspace: { + id: "saved-1", + name: "Workspace", + layoutJson, + updatedAt: 1, + }, + activeChat: { + workspacePath: "/workspace/codegraff-gui", + conversationId: "chat-1", + }, + }); + }); + test("renaming a saved workspace updates the saved workspace selection", async () => { sessionStore.getState().setBoardSelection({ activeChat: { @@ -1136,18 +1225,18 @@ describe("SessionProvider", () => { kind: "saved-workspace", workspace: { id: "saved-1", - layoutJson: "{\"grid\":true}", + layoutJson: '{"grid":true}', name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); - sessionStore.getState().applySessionSnapshot( - createSnapshot("/workspace/codegraff-gui", "chat-1"), - ); + sessionStore + .getState() + .applySessionSnapshot( + createSnapshot("/workspace/codegraff-gui", "chat-1"), + ); - let capturedActions: - | SessionActionsContextValue - | null = null; + let capturedActions: SessionActionsContextValue | null = null; function CaptureActions() { capturedActions = useContext(SessionActionsContext); @@ -1164,14 +1253,13 @@ describe("SessionProvider", () => { throw new Error("Expected session actions to be available"); } - const actions = - capturedActions as SessionActionsContextValue; + const actions = capturedActions as SessionActionsContextValue; await actions.renameSavedWorkspace("saved-1", "Renamed workspace"); expect(renameSavedWorkspaceCallCount).toBe(1); expect(sessionStore.getState().savedWorkspaces).toEqual([ - { id: "saved-1", name: "Renamed workspace", updatedAt: 2n }, + { id: "saved-1", name: "Renamed workspace", updatedAt: 2 }, ]); expect(sessionStore.getState().selection).toEqual({ activeChat: { @@ -1181,9 +1269,9 @@ describe("SessionProvider", () => { kind: "saved-workspace", workspace: { id: "saved-1", - layoutJson: "{\"grid\":true}", + layoutJson: '{"grid":true}', name: "Renamed workspace", - updatedAt: 2n, + updatedAt: 2, }, }); }); diff --git a/gui/src/app/SessionProvider.tsx b/gui/src/app/SessionProvider.tsx index 50d7d552..8fc59501 100644 --- a/gui/src/app/SessionProvider.tsx +++ b/gui/src/app/SessionProvider.tsx @@ -9,6 +9,7 @@ import type { } from "../services/desktop/types/contracts"; import { extractBindingsFromLayoutJson } from "../components/workspace-board/layout"; import { SessionActionsContext } from "./SessionContext"; +import type { SubmitPromptInput } from "./types/sessionContext"; import { openWorkspaceInTarget, runWorkspaceRuntimeStatusAction, @@ -22,7 +23,6 @@ import { ensureWorkspacePromptSettingsLoaded, ensureWorkspaceRuntimeStatusLoaded, getAttachments, - getConversationStoreKeyForBinding, getConversationView, getSelectionFromSnapshot, isLatestConversationSelectionRequest, @@ -53,9 +53,8 @@ function snapshotHasConversationView( } export function SessionProvider({ children }: SessionProviderProps) { - const activeWorkspacePath = useStore( - sessionStore, - (state) => getUiActiveWorkspacePath(state), + const activeWorkspacePath = useStore(sessionStore, (state) => + getUiActiveWorkspacePath(state), ); const applySessionSnapshot = useCallback((snapshot: SessionSnapshot) => { @@ -70,7 +69,9 @@ export function SessionProvider({ children }: SessionProviderProps) { const syncBoardSelectionFromSnapshot = useCallback( (snapshot: SessionSnapshot) => { - sessionStore.getState().setBoardSelection(getSelectionFromSnapshot(snapshot)); + sessionStore + .getState() + .setBoardSelection(getSelectionFromSnapshot(snapshot)); }, [], ); @@ -96,6 +97,16 @@ export function SessionProvider({ children }: SessionProviderProps) { return; } + const activeWorkspace = sessionStore + .getState() + .workspaces.find( + (workspace) => workspace.workspacePath === activeWorkspacePath, + ); + if (activeWorkspace?.kind === "managed_chat") { + window.localStorage.removeItem(LATEST_WORKSPACE_STORAGE_KEY); + return; + } + window.localStorage.setItem( LATEST_WORKSPACE_STORAGE_KEY, activeWorkspacePath, @@ -124,29 +135,32 @@ export function SessionProvider({ children }: SessionProviderProps) { ); }, []); - const openWorkspaceByPath = useCallback(async (workspacePath: string) => { - const store = sessionStore.getState(); - store.setIsOpeningProject(true); - try { - const snapshot = await runSnapshotCommand(() => - desktopClient.openWorkspace(workspacePath), - ); - if (snapshot == null) { - return; - } + const openWorkspaceByPath = useCallback( + async (workspacePath: string) => { + const store = sessionStore.getState(); + store.setIsOpeningProject(true); + try { + const snapshot = await runSnapshotCommand(() => + desktopClient.openWorkspace(workspacePath), + ); + if (snapshot == null) { + return; + } - applySessionSnapshot(snapshot); - syncBoardSelectionFromSnapshot(snapshot); - ensureWorkspaceMeta(workspacePath, { forceRuntimeStatus: true }); - } finally { - sessionStore.getState().setIsOpeningProject(false); - } - }, [ - applySessionSnapshot, - ensureWorkspaceMeta, - runSnapshotCommand, - syncBoardSelectionFromSnapshot, - ]); + applySessionSnapshot(snapshot); + syncBoardSelectionFromSnapshot(snapshot); + ensureWorkspaceMeta(workspacePath, { forceRuntimeStatus: true }); + } finally { + sessionStore.getState().setIsOpeningProject(false); + } + }, + [ + applySessionSnapshot, + ensureWorkspaceMeta, + runSnapshotCommand, + syncBoardSelectionFromSnapshot, + ], + ); // `codegraff ` (code-style): open the path the launcher handed us — // either stashed before launch (cold start) or forwarded to this running @@ -161,7 +175,7 @@ export function SessionProvider({ children }: SessionProviderProps) { void openWorkspaceByPath(pending); } } catch { - // No pending path (or not running under Tauri) — ignore. + // No pending path (or native bridge unavailable) — ignore. } try { unlisten = await desktopClient.onOpenWorkspacePath((path) => { @@ -321,35 +335,19 @@ export function SessionProvider({ children }: SessionProviderProps) { const bindings = extractBindingsFromLayoutJson(workspace.layoutJson); const initialBinding = bindings[0] ?? null; - const uniqueBindings = Array.from( - new Map( - bindings.map((binding) => [ - getConversationStoreKeyForBinding(binding), - binding, - ]), - ).values(), - ); sessionStore.getState().setBoardSelection({ kind: "saved-workspace", workspace, activeChat: initialBinding, }); - await Promise.all( - uniqueBindings.map((binding) => ensureConversationViewLoaded(binding)), - ); - - const workspacePaths = Array.from( - new Set(uniqueBindings.map((binding) => binding.workspacePath)), - ); - workspacePaths.forEach((workspacePath) => { - ensureWorkspaceMeta(workspacePath); - }); - if (initialBinding == null) { return; } + ensureWorkspaceMeta(initialBinding.workspacePath); + await ensureConversationViewLoaded(initialBinding); + const snapshot = await runSnapshotCommand(() => desktopClient.selectConversation( initialBinding.workspacePath, @@ -540,7 +538,9 @@ export function SessionProvider({ children }: SessionProviderProps) { }) => { const activeBinding = getUiActiveBinding(sessionStore.getState()); const followupRequest = - activeBinding == null ? null : getConversationView(activeBinding)?.followup ?? null; + activeBinding == null + ? null + : (getConversationView(activeBinding)?.followup ?? null); if (followupRequest == null) { return; } @@ -559,21 +559,42 @@ export function SessionProvider({ children }: SessionProviderProps) { } await desktopClient.stopPrompt(activeBinding).catch(() => null); + sessionStore + .getState() + .setPromptDraftPending( + getPromptDraftKey( + activeBinding.workspacePath, + activeBinding.conversationId, + ), + false, + ); + await desktopClient + .getSessionSnapshot() + .then(applySessionSnapshot) + .catch(() => null); }, - submitPrompt: async () => { - const workspacePath = getUiActiveWorkspacePath(sessionStore.getState()); + submitPrompt: async (input?: SubmitPromptInput) => { + const state = sessionStore.getState(); + const workspacePath = + typeof input === "object" && input != null && "workspacePath" in input + ? input.workspacePath ?? null + : getUiActiveWorkspacePath(state); if (workspacePath == null) { return; } - const conversationId = getUiActiveConversationId(sessionStore.getState()); + const conversationId = + typeof input === "object" && input != null && "conversationId" in input + ? input.conversationId ?? null + : getUiActiveConversationId(state); const promptDraftKey = getPromptDraftKey(workspacePath, conversationId); if (promptDraftKey == null) { return; } const draftState = getPromptDraftState(promptDraftKey); - const trimmedPrompt = draftState.value.trim(); + const draftValue = typeof input === "string" ? input : input?.draft ?? draftState.value; + const trimmedPrompt = draftValue.trim(); if (trimmedPrompt.length === 0) { return; } @@ -583,22 +604,55 @@ export function SessionProvider({ children }: SessionProviderProps) { // submit path in useConversationActions. const attachments = getAttachments(promptDraftKey); const prompt = appendAttachmentsToPrompt(trimmedPrompt, attachments); + const restorePromptDraft = () => { + const store = sessionStore.getState(); + store.setPromptDraftValue(promptDraftKey, draftValue); + store.setPromptDraftPlanningMode( + promptDraftKey, + draftState.isPlanningMode, + ); + store.setPromptDraftUltraMode(promptDraftKey, draftState.isUltraMode); + store.clearAttachments(promptDraftKey); + store.addAttachments(promptDraftKey, attachments); + }; // Clear the welcome composer immediately on send (prompt already // captured) so the draft + attachment don't linger while the turn runs. sessionStore.getState().clearPromptDraft(promptDraftKey); sessionStore.getState().clearAttachments(promptDraftKey); let nextPromptDraftKey: string | null = promptDraftKey; - sessionStore.getState().setPromptDraftPending(promptDraftKey, true); + const agentId = draftState.isPlanningMode ? "muse" : "forge"; + const store = sessionStore.getState(); + const optimisticRequestId = `optimistic-${Date.now()}`; + const optimisticBinding = { + conversationId: conversationId ?? `optimistic-chat-${Date.now()}`, + workspacePath, + }; + store.setPromptDraftPending(promptDraftKey, true); + store.appendOptimisticUserMessage( + optimisticBinding, + optimisticRequestId, + prompt, + agentId, + ); - const snapshot = await runSnapshotCommand(() => - desktopClient.sendPrompt({ - agentId: draftState.isPlanningMode ? "muse" : "forge", + const snapshot = await desktopClient + .sendPrompt({ + agentId, conversationId, prompt, workspacePath, - }), - ); + }) + .catch(() => null); + if (snapshot == null) { + sessionStore.getState().removeOptimisticRequest( + optimisticBinding, + optimisticRequestId, + ); + restorePromptDraft(); + sessionStore.getState().setPromptDraftPending(promptDraftKey, false); + return; + } if (snapshot != null) { applySessionSnapshot(snapshot); nextPromptDraftKey = getPromptDraftKey( @@ -635,7 +689,10 @@ export function SessionProvider({ children }: SessionProviderProps) { return; } - await updateWorkspacePromptSettings(workspacePath, input); + const promptSettings = await updateWorkspacePromptSettings(workspacePath, input); + if (promptSettings == null) { + throw new Error("Failed to update prompt settings"); + } }, }), [ diff --git a/gui/src/app/sessionClientActions.ts b/gui/src/app/sessionClientActions.ts index 7ef334c4..435e2e66 100644 --- a/gui/src/app/sessionClientActions.ts +++ b/gui/src/app/sessionClientActions.ts @@ -7,6 +7,8 @@ import type { WorkspacePromptSettingsUpdateInput, } from "./types/sessionClientActions"; +const promptSettingsUpdateSeqByWorkspace = new Map(); + export async function runWorkspaceRuntimeStatusAction( workspacePath: string, action: () => Promise, @@ -64,6 +66,13 @@ export async function updateWorkspacePromptSettings( workspacePath: string, input: WorkspacePromptSettingsUpdateInput, ) { + const requestSeq = + (promptSettingsUpdateSeqByWorkspace.get(workspacePath) ?? 0) + 1; + promptSettingsUpdateSeqByWorkspace.set(workspacePath, requestSeq); + + const isLatestRequest = () => + promptSettingsUpdateSeqByWorkspace.get(workspacePath) === requestSeq; + try { const promptSettings = await desktopClient.updatePromptSettings({ modelId: input.modelId, @@ -71,12 +80,20 @@ export async function updateWorkspacePromptSettings( reasoningEffort: input.reasoningEffort ?? null, workspacePath, }); + if (!isLatestRequest()) { + return promptSettings; + } + sessionStore.getState().setWorkspacePromptSettings(workspacePath, promptSettings); try { const runtimeStatus = await desktopClient.getRuntimeStatus(workspacePath); - sessionStore.getState().setWorkspaceRuntimeStatus(workspacePath, runtimeStatus); + if (isLatestRequest()) { + sessionStore.getState().setWorkspaceRuntimeStatus(workspacePath, runtimeStatus); + } } catch { - sessionStore.getState().setWorkspaceRuntimeStatus(workspacePath, null); + if (isLatestRequest()) { + sessionStore.getState().setWorkspaceRuntimeStatus(workspacePath, null); + } } return promptSettings; } catch { diff --git a/gui/src/app/sessionStore.test.ts b/gui/src/app/sessionStore.test.ts index 4875aa04..925ab2a5 100644 --- a/gui/src/app/sessionStore.test.ts +++ b/gui/src/app/sessionStore.test.ts @@ -1,8 +1,9 @@ -import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; import type { PromptSettings, RuntimeStatus, + SessionMessage, SessionSnapshot, } from "../services/desktop/types/contracts"; @@ -29,7 +30,7 @@ function deferred() { } const runtimeStatusFixture: RuntimeStatus = { - availableOpenTargets: ["cursor"], + availableOpenTargets: ["file-manager"], configurationError: null, configured: true, gitBranchName: "main", @@ -67,7 +68,7 @@ function createSnapshot( workspacePath: "/workspace/codegraff-gui", }, ], - savedWorkspaces: [{ id: "saved-1", name: "Workspace", updatedAt: 1n }], + savedWorkspaces: [{ id: "saved-1", name: "Workspace", updatedAt: 1 }], uiError: null, visibleActiveRequestIds: [], visibleFollowup: null, @@ -100,10 +101,19 @@ function createSnapshot( mock.module("../services/desktop/client", () => ({ setFast: async () => {}, + saveAttachmentFile: async (input: { name: string }) => `/tmp/${input.name}`, savePastedImage: async () => "/tmp/pasted-image.png", + readClipboardText: async () => "", + writeClipboardText: async () => {}, + pickDirectory: async () => "/workspace/codegraff-gui", + drainPendingOpen: async () => null, imageThumbnail: async () => "data:image/jpeg;base64,", openExternalUrl: async () => {}, + openInTarget: async () => {}, + openPathDefault: async () => {}, + openPathForEdit: async () => {}, openPathInTarget: async () => {}, + setWindowTitle: async () => {}, ensureConversationView: async (workspacePath: string, conversationId: string) => { conversationViewCallCount += 1; return await conversationViewImpl(workspacePath, conversationId); @@ -131,6 +141,10 @@ const { sessionStore, } = await import("./sessionStore"); +afterAll(() => { + mock.restore(); +}); + describe("sessionStore", () => { beforeEach(() => { conversationViewCallCount = 0; @@ -188,7 +202,7 @@ describe("sessionStore", () => { id: "saved-1", layoutJson: "{\"grid\":true}", name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); @@ -208,7 +222,7 @@ describe("sessionStore", () => { id: "saved-1", layoutJson: "{\"grid\":true}", name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); }); @@ -224,13 +238,13 @@ describe("sessionStore", () => { id: "saved-1", layoutJson: "{\"grid\":true}", name: "Workspace", - updatedAt: 1n, + updatedAt: 1, }, }); sessionStore.getState().applySessionSnapshot( createSnapshot({ - savedWorkspaces: [{ id: "saved-1", name: "Renamed workspace", updatedAt: 2n }], + savedWorkspaces: [{ id: "saved-1", name: "Renamed workspace", updatedAt: 2 }], }), ); @@ -244,7 +258,7 @@ describe("sessionStore", () => { id: "saved-1", layoutJson: "{\"grid\":true}", name: "Renamed workspace", - updatedAt: 2n, + updatedAt: 2, }, }); }); @@ -353,6 +367,69 @@ describe("sessionStore", () => { ).toBeUndefined(); }); + test("message deltas maintain per-conversation message indices and versions", () => { + const historicalMessages: SessionMessage[] = Array.from( + { length: 250 }, + (_value, index) => ({ + id: `assistant-old-${index}`, + kind: "assistant" as const, + requestId: `old-${index}`, + text: `historical ${index}`, + }), + ); + sessionStore.getState().applySessionSnapshot( + createSnapshot({ + conversationViews: [ + { + activeRequestIds: ["req-stream"], + conversationId: "chat-1", + followup: null, + messages: historicalMessages, + requestAgentIds: {}, + todos: [], + workspacePath: "/workspace/codegraff-gui", + }, + ], + }), + ); + + const key = "/workspace/codegraff-gui::chat-1"; + const initialVersion = sessionStore.getState().conversationVersionsByKey[key] ?? 0; + + sessionStore.getState().appendMessageDelta({ + conversationId: "chat-1", + kind: "assistant", + messageId: "streaming-message", + requestId: "req-stream", + text: "hello", + workspacePath: "/workspace/codegraff-gui", + }); + for (let index = 0; index < 100; index += 1) { + sessionStore.getState().appendMessageDelta({ + conversationId: "chat-1", + kind: "assistant", + messageId: "streaming-message", + requestId: "req-stream", + text: "!", + workspacePath: "/workspace/codegraff-gui", + }); + } + + const state = sessionStore.getState(); + const view = state.conversationViewsByKey[key]; + const messageIndex = state.conversationMessageIndicesByKey[key]?.[ + "streaming-message" + ]; + expect(messageIndex).toBe(250); + expect(view?.messages[messageIndex ?? -1]).toEqual({ + id: "streaming-message", + kind: "assistant", + requestId: "req-stream", + text: `hello${"!".repeat(100)}`, + }); + expect(state.conversationVersionsByKey[key]).toBe(initialVersion + 101); + }); + test("workspace draft selection ignores a stale active conversation", () => { sessionStore.getState().applySessionSnapshot(createSnapshot()); sessionStore.getState().setBoardSelection({ diff --git a/gui/src/app/sessionStore.ts b/gui/src/app/sessionStore.ts index bfb7dff2..f2e233f0 100644 --- a/gui/src/app/sessionStore.ts +++ b/gui/src/app/sessionStore.ts @@ -14,6 +14,7 @@ import type { import type { WorkspaceBoardSelection } from "./types/sessionContext"; import type { PromptDraftEntry, + QueuedPromptEntry, SessionStoreSetter, SessionStoreState, WorkspaceMetaState, @@ -182,6 +183,28 @@ function movePromptDraftEntry( return nextDrafts; } +function shiftQueuedPromptEntry( + queue: Record, + key: string, +): { + entry: QueuedPromptEntry | null; + nextQueue: Record; +} { + const existing = queue[key] ?? []; + if (existing.length === 0) { + return { entry: null, nextQueue: queue }; + } + + const [entry, ...remaining] = existing; + const nextQueue = { ...queue }; + if (remaining.length === 0) { + delete nextQueue[key]; + } else { + nextQueue[key] = remaining; + } + return { entry, nextQueue }; +} + export function getSelectionFromSnapshot( snapshot: SessionSnapshot, ): WorkspaceBoardSelection { @@ -258,6 +281,24 @@ function buildConversationSummariesByKey(workspaces: WorkspaceSession[]) { return byKey; } +function buildMessageIndexById(messages: ConversationViewSnapshot["messages"]) { + const indexById: Record = {}; + messages.forEach((message, index) => { + indexById[message.id] = index; + }); + return indexById; +} + +function bumpConversationVersion( + versions: Record, + key: string, +): Record { + return { + ...versions, + [key]: (versions[key] ?? 0) + 1, + }; +} + export function areSelectionsEqual( left: WorkspaceBoardSelection, right: WorkspaceBoardSelection, @@ -341,10 +382,13 @@ function createSessionStoreState(set: SessionStoreSetter): SessionStoreState { activeWorkspacePath: null, attachmentsByKey: {}, conversationSummariesByKey: {}, + conversationMessageIndicesByKey: {}, + conversationVersionsByKey: {}, conversationViewsByKey: {}, isBootstrapped: false, isOpeningProject: false, promptDraftsByKey: {}, + queuedPromptsByKey: {}, requestTimingsByConversationId: {}, savedWorkspaces: [], selection: { kind: "empty" }, @@ -405,10 +449,30 @@ function createSessionStoreState(set: SessionStoreSetter): SessionStoreState { } const nextConversationViewsByKey = { ...current.conversationViewsByKey }; + let nextConversationMessageIndicesByKey = + current.conversationMessageIndicesByKey; + let nextConversationVersionsByKey = current.conversationVersionsByKey; for (const view of nextViews) { - nextConversationViewsByKey[ - getConversationStoreKey(view.workspacePath, view.conversationId) - ] = view; + const key = getConversationStoreKey(view.workspacePath, view.conversationId); + const previousView = current.conversationViewsByKey[key]; + nextConversationViewsByKey[key] = view; + if (previousView !== view) { + if ( + nextConversationMessageIndicesByKey === + current.conversationMessageIndicesByKey + ) { + nextConversationMessageIndicesByKey = { + ...current.conversationMessageIndicesByKey, + }; + } + nextConversationMessageIndicesByKey[key] = buildMessageIndexById( + view.messages, + ); + nextConversationVersionsByKey = bumpConversationVersion( + nextConversationVersionsByKey, + key, + ); + } } return { @@ -417,6 +481,8 @@ function createSessionStoreState(set: SessionStoreSetter): SessionStoreState { conversationSummariesByKey: buildConversationSummariesByKey( snapshot.workspaces, ), + conversationMessageIndicesByKey: nextConversationMessageIndicesByKey, + conversationVersionsByKey: nextConversationVersionsByKey, conversationViewsByKey: nextConversationViewsByKey, requestTimingsByConversationId: nextRequestTimingsByConversationId, savedWorkspaces: snapshot.savedWorkspaces, @@ -446,6 +512,271 @@ function createSessionStoreState(set: SessionStoreSetter): SessionStoreState { }; }); }, + + appendMessageDelta: (delta) => { + set((current) => { + const key = getConversationStoreKey( + delta.workspacePath, + delta.conversationId, + ); + const existing = current.conversationViewsByKey[key] ?? { + activeRequestIds: [], + conversationId: delta.conversationId, + followup: null, + messages: [], + requestAgentIds: {}, + todos: [], + workspacePath: delta.workspacePath, + } satisfies ConversationViewSnapshot; + const existingIndexById = + current.conversationMessageIndicesByKey[key] ?? + buildMessageIndexById(existing.messages); + const messageIndex = existingIndexById[delta.messageId]; + let nextIndexById = existingIndexById; + let messages = existing.messages; + + if (messageIndex != null) { + const message = existing.messages[messageIndex]; + if ( + message?.kind === "assistant" || + message?.kind === "reasoning" + ) { + messages = existing.messages.slice(); + messages[messageIndex] = { + ...message, + text: `${message.text}${delta.text}`, + }; + } + } else { + messages = [ + ...existing.messages, + { + id: delta.messageId, + kind: delta.kind, + requestId: delta.requestId, + text: delta.text, + }, + ]; + nextIndexById = { + ...existingIndexById, + [delta.messageId]: existing.messages.length, + }; + } + + if (messages === existing.messages) { + return current; + } + + const activeRequestIds = existing.activeRequestIds.includes( + delta.requestId, + ) + ? existing.activeRequestIds + : [...existing.activeRequestIds, delta.requestId]; + return { + activeConversationId: current.activeConversationId ?? delta.conversationId, + activeWorkspacePath: current.activeWorkspacePath ?? delta.workspacePath, + conversationMessageIndicesByKey: + nextIndexById === existingIndexById && + current.conversationMessageIndicesByKey[key] != null + ? current.conversationMessageIndicesByKey + : { + ...current.conversationMessageIndicesByKey, + [key]: nextIndexById, + }, + conversationVersionsByKey: bumpConversationVersion( + current.conversationVersionsByKey, + key, + ), + conversationViewsByKey: { + ...current.conversationViewsByKey, + [key]: { + ...existing, + activeRequestIds, + messages, + }, + }, + }; + }); + }, + + markRequestFinished: (event) => { + set((current) => { + const key = getConversationStoreKey( + event.workspacePath, + event.conversationId, + ); + const existing = current.conversationViewsByKey[key]; + const now = Date.now(); + const currentConversationTimings = + current.requestTimingsByConversationId[event.conversationId] ?? {}; + const timing = currentConversationTimings[event.requestId]; + const nextTimings = { + ...currentConversationTimings, + [event.requestId]: { + completedAtMs: now, + startedAtMs: timing?.startedAtMs ?? now, + }, + }; + if (existing == null) { + return { + requestTimingsByConversationId: { + ...current.requestTimingsByConversationId, + [event.conversationId]: nextTimings, + }, + }; + } + return { + conversationVersionsByKey: bumpConversationVersion( + current.conversationVersionsByKey, + key, + ), + conversationViewsByKey: { + ...current.conversationViewsByKey, + [key]: { + ...existing, + activeRequestIds: existing.activeRequestIds.filter( + (requestId) => requestId !== event.requestId, + ), + }, + }, + requestTimingsByConversationId: { + ...current.requestTimingsByConversationId, + [event.conversationId]: nextTimings, + }, + }; + }); + }, + + appendOptimisticUserMessage: (binding, requestId, text, agentId) => { + set((current) => { + const key = getConversationStoreKey( + binding.workspacePath, + binding.conversationId, + ); + const existing = current.conversationViewsByKey[key] ?? { + activeRequestIds: [], + conversationId: binding.conversationId, + followup: null, + messages: [], + requestAgentIds: {}, + todos: [], + workspacePath: binding.workspacePath, + } satisfies ConversationViewSnapshot; + if (existing.messages.some((message) => message.id === `${requestId}-user`)) { + return current; + } + const messageId = `${requestId}-user`; + const messages = [ + ...existing.messages, + { + kind: "user" as const, + id: messageId, + requestId, + text, + }, + ]; + return { + activeConversationId: binding.conversationId, + activeWorkspacePath: binding.workspacePath, + conversationMessageIndicesByKey: { + ...current.conversationMessageIndicesByKey, + [key]: { + ...(current.conversationMessageIndicesByKey[key] ?? + buildMessageIndexById(existing.messages)), + [messageId]: existing.messages.length, + }, + }, + conversationVersionsByKey: bumpConversationVersion( + current.conversationVersionsByKey, + key, + ), + conversationViewsByKey: { + ...current.conversationViewsByKey, + [key]: { + ...existing, + activeRequestIds: existing.activeRequestIds.includes(requestId) + ? existing.activeRequestIds + : [...existing.activeRequestIds, requestId], + messages, + requestAgentIds: + agentId == null + ? existing.requestAgentIds + : { ...existing.requestAgentIds, [requestId]: agentId }, + }, + }, + selection: { + kind: "single-chat", + chat: binding, + }, + }; + }); + }, + + removeOptimisticRequest: (binding, requestId) => { + set((current) => { + const key = getConversationStoreKey( + binding.workspacePath, + binding.conversationId, + ); + const existing = current.conversationViewsByKey[key]; + if (existing == null) { + return current; + } + const nextView = { + ...existing, + activeRequestIds: existing.activeRequestIds.filter( + (id) => id !== requestId, + ), + messages: existing.messages.filter( + (message) => message.requestId !== requestId, + ), + requestAgentIds: Object.fromEntries( + Object.entries(existing.requestAgentIds).filter( + ([id]) => id !== requestId, + ), + ), + }; + const nextViewsByKey = { ...current.conversationViewsByKey }; + const hasSummary = current.conversationSummariesByKey[key] != null; + const shouldDropEmptyOptimisticView = + !hasSummary && + nextView.messages.length === 0 && + nextView.activeRequestIds.length === 0; + let nextMessageIndicesByKey = current.conversationMessageIndicesByKey; + if (shouldDropEmptyOptimisticView) { + delete nextViewsByKey[key]; + if (current.conversationMessageIndicesByKey[key] != null) { + nextMessageIndicesByKey = { ...current.conversationMessageIndicesByKey }; + delete nextMessageIndicesByKey[key]; + } + } else { + nextViewsByKey[key] = nextView; + nextMessageIndicesByKey = { + ...current.conversationMessageIndicesByKey, + [key]: buildMessageIndexById(nextView.messages), + }; + } + const isActiveOptimisticView = + current.activeWorkspacePath === binding.workspacePath && + current.activeConversationId === binding.conversationId; + return { + activeConversationId: + shouldDropEmptyOptimisticView && isActiveOptimisticView + ? null + : current.activeConversationId, + conversationMessageIndicesByKey: nextMessageIndicesByKey, + conversationVersionsByKey: bumpConversationVersion( + current.conversationVersionsByKey, + key, + ), + conversationViewsByKey: nextViewsByKey, + selection: + shouldDropEmptyOptimisticView && isActiveOptimisticView + ? { kind: "workspace-draft", workspacePath: binding.workspacePath } + : current.selection, + }; + }); + }, addAttachments: (key, items) => { if (key == null || items.length === 0) { return; @@ -517,6 +848,37 @@ function createSessionStoreState(set: SessionStoreSetter): SessionStoreState { ), })); }, + dequeuePrompt: (key) => { + if (key == null) { + return null; + } + + let dequeued: QueuedPromptEntry | null = null; + set((current) => { + const { entry, nextQueue } = shiftQueuedPromptEntry( + current.queuedPromptsByKey, + key, + ); + dequeued = entry; + if (entry == null) { + return current; + } + return { queuedPromptsByKey: nextQueue }; + }); + return dequeued; + }, + enqueuePrompt: (key, entry) => { + if (key == null) { + return; + } + + set((current) => ({ + queuedPromptsByKey: { + ...current.queuedPromptsByKey, + [key]: [...(current.queuedPromptsByKey[key] ?? []), entry], + }, + })); + }, movePromptDraft: (fromKey, toKey) => { set((current) => ({ promptDraftsByKey: movePromptDraftEntry( diff --git a/gui/src/app/themeStore.ts b/gui/src/app/themeStore.ts index 10b6700a..41038b76 100644 --- a/gui/src/app/themeStore.ts +++ b/gui/src/app/themeStore.ts @@ -21,6 +21,11 @@ export const THEME_PRESET_IDS: ThemePresetId[] = [ const MODE_STORAGE_KEY = "codegraff:theme"; const PRESET_STORAGE_KEY = "codegraff:theme-preset"; +type PersistedThemeSettings = { + mode: ThemeMode | null; + preset: ThemePresetId | null; +}; + function readStoredMode(): ThemeMode | null { try { const value = window.localStorage.getItem(MODE_STORAGE_KEY); @@ -69,6 +74,27 @@ function persistPreset(preset: ThemePresetId) { } } +function persistThemeSettings(mode: ThemeMode, preset: ThemePresetId) { + void fetch("/api/update_theme_settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: { mode, preset } }), + }).catch(() => { + // Keep localStorage as a same-origin fallback if the backend is unavailable. + }); +} + +function isThemeMode(value: unknown): value is ThemeMode { + return value === "light" || value === "dark"; +} + +function isThemePreset(value: unknown): value is ThemePresetId { + return ( + typeof value === "string" && + THEME_PRESET_IDS.includes(value as ThemePresetId) + ); +} + const initialMode = readInitialMode(); const initialPreset = readStoredPreset() ?? "warm-graphite"; @@ -93,17 +119,45 @@ export const useThemeStore = create((set, get) => ({ setMode: (mode) => { persistMode(mode); applyTheme(mode, get().preset); + persistThemeSettings(mode, get().preset); set({ mode }); }, toggleMode: () => { const mode = get().mode === "dark" ? "light" : "dark"; persistMode(mode); applyTheme(mode, get().preset); + persistThemeSettings(mode, get().preset); set({ mode }); }, setPreset: (preset) => { persistPreset(preset); applyTheme(get().mode, preset); + persistThemeSettings(get().mode, preset); set({ preset }); }, })); + +export async function hydrateThemeSettings() { + try { + const response = await fetch("/api/get_theme_settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + if (!response.ok) { + return; + } + const settings = (await response.json()) as PersistedThemeSettings; + const current = useThemeStore.getState(); + const mode = isThemeMode(settings.mode) ? settings.mode : current.mode; + const preset = isThemePreset(settings.preset) + ? settings.preset + : current.preset; + persistMode(mode); + persistPreset(preset); + applyTheme(mode, preset); + useThemeStore.setState({ mode, preset }); + } catch { + // localStorage already supplied the initial theme. + } +} diff --git a/gui/src/app/types/sessionContext.ts b/gui/src/app/types/sessionContext.ts index ea3e58e7..b03ce2ae 100644 --- a/gui/src/app/types/sessionContext.ts +++ b/gui/src/app/types/sessionContext.ts @@ -10,6 +10,18 @@ export interface RequestTimingInfo { startedAtMs: number; } +export type SubmitPromptInput = + | string + | { + conversationId?: string | null; + draft: string; + workspacePath?: string | null; + }; + +export function getSubmitPromptDraft(input?: SubmitPromptInput): string | undefined { + return typeof input === "string" ? input : input?.draft; +} + export interface SessionActionsContextValue { openWorkspacePicker: () => Promise; openProject: (workspacePath: string) => Promise; @@ -42,7 +54,7 @@ export interface SessionActionsContextValue { reasoningEffort?: string | null; }) => Promise; stopPrompt: () => Promise; - submitPrompt: () => Promise; + submitPrompt: (input?: SubmitPromptInput) => Promise; submitFollowup: (input: { cancelled: boolean; text?: string; diff --git a/gui/src/app/types/sessionStore.ts b/gui/src/app/types/sessionStore.ts index 792de3bd..b1224a73 100644 --- a/gui/src/app/types/sessionStore.ts +++ b/gui/src/app/types/sessionStore.ts @@ -6,6 +6,7 @@ import type { PromptSettings, RuntimeStatus, SavedWorkspaceSummary, + SessionMessage, SessionSnapshot, WorkspaceSession, } from "@/services/desktop/types/contracts"; @@ -29,6 +30,13 @@ export interface PromptDraftEntry { value: string; } +export interface QueuedPromptEntry { + attachments: Attachment[]; + isPlanningMode: boolean; + isUltraMode: boolean; + value: string; +} + export interface WorkspaceMetaState { promptSettings: PromptSettings | null; promptSettingsLoaded: boolean; @@ -41,10 +49,13 @@ export interface SessionStoreState { activeWorkspacePath: string | null; attachmentsByKey: Record; conversationSummariesByKey: Record; + conversationMessageIndicesByKey: Record>; + conversationVersionsByKey: Record; conversationViewsByKey: Record; isBootstrapped: boolean; isOpeningProject: boolean; promptDraftsByKey: Record; + queuedPromptsByKey: Record; requestTimingsByConversationId: Record< string, Record @@ -56,11 +67,17 @@ export interface SessionStoreState { workspaces: WorkspaceSession[]; workspacesByPath: Record; applySessionSnapshot: (snapshot: SessionSnapshot) => void; + appendMessageDelta: (delta: { workspacePath: string; conversationId: string; requestId: string; messageId: string; kind: Extract; text: string }) => void; + markRequestFinished: (event: { workspacePath: string; conversationId: string; requestId: string }) => void; + appendOptimisticUserMessage: (binding: { workspacePath: string; conversationId: string }, requestId: string, text: string, agentId?: string | null) => void; + removeOptimisticRequest: (binding: { workspacePath: string; conversationId: string }, requestId: string) => void; addAttachments: (key: string | null, items: Attachment[]) => void; removeAttachment: (key: string | null, id: string) => void; clearAttachments: (key: string | null) => void; moveAttachments: (fromKey: string | null, toKey: string | null) => void; clearPromptDraft: (key: string | null) => void; + dequeuePrompt: (key: string | null) => QueuedPromptEntry | null; + enqueuePrompt: (key: string | null, entry: QueuedPromptEntry) => void; movePromptDraft: (fromKey: string | null, toKey: string | null) => void; setBoardSelection: (selection: WorkspaceBoardSelection) => void; setIsBootstrapped: (value: boolean) => void; diff --git a/gui/src/components/ConversationPanel.tsx b/gui/src/components/ConversationPanel.tsx index cadeb687..e0d4ee64 100644 --- a/gui/src/components/ConversationPanel.tsx +++ b/gui/src/components/ConversationPanel.tsx @@ -27,6 +27,7 @@ export function ConversationPanel({ activeWorkspaceLabel, activeWorkspaceConfigured, activeWorkspaceConfigurationError, + conversationVersion, followupRequest, hasCurrentWorkspace, isConversationLoading, @@ -113,6 +114,7 @@ export function ConversationPanel({ ([]) - const { submitFollowup } = useSessionActions() + const [notesText, setNotesText] = useState(""); + const [selectedOptionIds, setSelectedOptionIds] = useState([]); + const { submitFollowup } = useSessionActions(); + + const options = followupRequest.options ?? []; + const hasOptions = options.length > 0; + const isMulti = followupRequest.kind === "multi"; + const handleSubmit = (input: FollowupSubmitInput) => { - const action = onSubmit ?? submitFollowup - void action(input) + const action = onSubmit ?? submitFollowup; + void action(input); + }; + + const canContinue = hasOptions + ? selectedOptionIds.length > 0 || notesText.trim().length > 0 + : notesText.trim().length > 0; + + function toggleOption(optionId: string) { + setSelectedOptionIds((current) => + isMulti + ? current.includes(optionId) + ? current.filter((id) => id !== optionId) + : [...current, optionId] + : current.includes(optionId) + ? [] + : [optionId], + ); } - const canContinue = - followupRequest.kind === 'text' - ? followupText.trim().length > 0 - : selectedOptionIds.length > 0 + function submit() { + if (!canContinue) return; + handleSubmit({ + cancelled: false, + text: notesText.trim().length > 0 ? notesText : undefined, + selectedOptionIds: selectedOptionIds.length > 0 ? selectedOptionIds : undefined, + }); + } - function toggleFollowupOption(optionId: string) { - if (followupRequest.kind === 'single') { - setSelectedOptionIds([optionId]) - return + function handleNotesKeyDown(event: React.KeyboardEvent) { + // Enter submits the highlighted option + notes; Shift+Enter inserts a newline. + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + submit(); } - - setSelectedOptionIds((current) => - current.includes(optionId) - ? current.filter((id) => id !== optionId) - : [...current, optionId], - ) } return ( -
-

- Follow-up -

-

- {followupRequest.question} -

+
+ {/* Hairline accent gradient along the top edge — the only chrome. */} + +
+
+ + + Question + +
+

+ {followupRequest.question} +

- {followupRequest.kind === 'text' ? ( -