diff --git a/.gitignore b/.gitignore index d320e0b1..c0a2bf89 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ compaction-results/ syntax.jsonl out.jsonl pi-*.html +out.html diff --git a/.pi/commands/review.md b/.pi/commands/review.md deleted file mode 100644 index 3e1db779..00000000 --- a/.pi/commands/review.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -description: Review a file for issues ---- -Please review the following file for potential issues, bugs, or improvements: - -$1 - -Focus on: -- Logic errors -- Edge cases -- Code style -- Performance concerns diff --git a/.pi/hooks/test-command.ts b/.pi/hooks/test-command.ts deleted file mode 100644 index 7f737498..00000000 --- a/.pi/hooks/test-command.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Test hook demonstrating custom commands, message rendering, and before_agent_start. - */ -import type { BeforeAgentStartEvent, HookAPI } from "@mariozechner/pi-coding-agent"; -import { Box, Spacer, Text } from "@mariozechner/pi-tui"; - -export default function (pi: HookAPI) { - // Track whether injection is enabled - let injectEnabled = false; - - // Register a custom message renderer for our "test-info" type - pi.registerMessageRenderer("test-info", (message, options, theme) => { - const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t)); - - const label = theme.fg("success", "[TEST INFO]"); - box.addChild(new Text(label, 0, 0)); - box.addChild(new Spacer(1)); - - const content = - typeof message.content === "string" - ? message.content - : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join(""); - - box.addChild(new Text(theme.fg("text", content), 0, 0)); - - if (options.expanded && message.details) { - box.addChild(new Spacer(1)); - box.addChild(new Text(theme.fg("dim", `Details: ${JSON.stringify(message.details)}`), 0, 0)); - } - - return box; - }); - - // Register /test-msg command - pi.registerCommand("test-msg", { - description: "Send a test custom message", - handler: async () => { - pi.sendMessage( - { - customType: "test-info", - content: "This is a test message with custom rendering!", - display: true, - details: { timestamp: Date.now(), source: "test-command hook" }, - }, - true, // triggerTurn: start agent run - ); - }, - }); - - // Register /test-hidden command - pi.registerCommand("test-hidden", { - description: "Send a hidden message (display: false)", - handler: async (ctx) => { - pi.sendMessage({ - customType: "test-info", - content: "This message is in context but not displayed", - display: false, - }); - ctx.ui.notify("Sent hidden message (check session file)"); - }, - }); - - // Register /test-inject command to toggle before_agent_start injection - pi.registerCommand("test-inject", { - description: "Toggle context injection before agent starts", - handler: async (ctx) => { - injectEnabled = !injectEnabled; - ctx.ui.notify(`Context injection ${injectEnabled ? "enabled" : "disabled"}`); - }, - }); - - // Demonstrate before_agent_start: inject context when enabled - pi.on("before_agent_start", async (event: BeforeAgentStartEvent) => { - if (!injectEnabled) return; - - // Return a message to inject before the user's prompt - return { - message: { - customType: "test-info", - content: `[Injected context for prompt: "${event.prompt.slice(0, 50)}..."]`, - display: true, - details: { injectedAt: Date.now() }, - }, - }; - }); -} diff --git a/.pi/settings.json b/.pi/settings.json new file mode 100644 index 00000000..a9626a63 --- /dev/null +++ b/.pi/settings.json @@ -0,0 +1,4 @@ +{ + "customTools": ["packages/coding-agent/examples/custom-tools/todo/index.ts"], + "hooks": ["packages/coding-agent/examples/hooks/todo/index.ts"] +} diff --git a/AGENTS.md b/AGENTS.md index 686a1a32..b71720cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,9 @@ Use these sections under `## [Unreleased]`: - `### Removed` - Removed features ### Rules +- Before adding entries, read the full `[Unreleased]` section to see which subsections already exist - New entries ALWAYS go under `## [Unreleased]` section +- Append to existing subsections (e.g., `### Fixed`), do not create duplicates - NEVER modify already-released version sections (e.g., `## [0.12.2]`) - Each version section is immutable once released @@ -81,5 +83,6 @@ Use these sections under `## [Unreleased]`: The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. -### Tool Usage -**CTRICIAL**: NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). \ No newline at end of file +### **CRITICAL** Tool Usage Rules **CRITICAL** +- NEVER use sed/cat to read a file or a range of a file. Always use the read tool (use offset + limit for ranged reads). +- You MUST read every file you modify in full before editing. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3e84ed4d..741581d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -250,15 +250,25 @@ } }, "node_modules/@borewit/text-codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.1.1.tgz", - "integrity": "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", + "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@emnapi/runtime": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", + "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -696,6 +706,471 @@ } } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -734,29 +1209,6 @@ "node": ">=12" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1864,9 +2316,9 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.34.45", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.45.tgz", - "integrity": "sha512-qJcFVfCa5jxBFSuv7S5WYbA8XdeCPmhnaVVfX/2Y6L8WYg8sk3XY2+6W0zH+3mq1Cz+YC7Ki66HfqX6IHAwnkg==", + "version": "0.34.46", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.46.tgz", + "integrity": "sha512-kiW7CtS/NkdvTUjkjUJo7d5JsFfbJ14YjdhDk9KoEgK6nFjKNXZPrX0jfLA8ZlET4cFLHxOZ/0vFKOP+bOxIOQ==", "license": "MIT" }, "node_modules/@slack/logger": { @@ -2970,6 +3422,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/cliui/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3157,6 +3615,13 @@ "node": ">=12" } }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/concurrently/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3389,9 +3854,9 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, "node_modules/end-of-stream": { @@ -5382,7 +5847,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5397,6 +5861,50 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5530,16 +6038,17 @@ } }, "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "license": "MIT", "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=20" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5569,6 +6078,12 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -5846,12 +6361,12 @@ } }, "node_modules/token-types": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.1.tgz", - "integrity": "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", "license": "MIT", "dependencies": { - "@borewit/text-codec": "^0.1.0", + "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" }, @@ -6242,6 +6757,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6268,29 +6789,6 @@ "node": ">=8" } }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6376,6 +6874,12 @@ "node": ">=8" } }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6403,9 +6907,9 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.4.tgz", + "integrity": "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A==", "license": "MIT", "peer": true, "funding": { @@ -6423,11 +6927,11 @@ }, "packages/agent": { "name": "@mariozechner/pi-agent-core", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-tui": "^0.30.2" + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-tui": "^0.31.1" }, "devDependencies": { "@types/node": "^24.3.0", @@ -6457,7 +6961,7 @@ }, "packages/ai": { "name": "@mariozechner/pi-ai", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.71.2", @@ -6502,19 +7006,20 @@ }, "packages/coding-agent": { "name": "@mariozechner/pi-coding-agent", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.30.2", - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-tui": "^0.30.2", + "@mariozechner/pi-agent-core": "^0.31.1", + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-tui": "^0.31.1", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", "diff": "^8.0.2", "file-type": "^21.1.1", "glob": "^11.0.3", "jiti": "^2.6.1", - "marked": "^15.0.12" + "marked": "^15.0.12", + "sharp": "^0.34.2" }, "bin": { "pi": "dist/cli.js" @@ -6548,13 +7053,13 @@ }, "packages/mom": { "name": "@mariozechner/pi-mom", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.30.2", - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-coding-agent": "^0.30.2", + "@mariozechner/pi-agent-core": "^0.31.1", + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-coding-agent": "^0.31.1", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", @@ -6593,10 +7098,10 @@ }, "packages/pods": { "name": "@mariozechner/pi", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { - "@mariozechner/pi-agent-core": "^0.30.2", + "@mariozechner/pi-agent-core": "^0.31.1", "chalk": "^5.5.0" }, "bin": { @@ -6609,14 +7114,14 @@ }, "packages/tui": { "name": "@mariozechner/pi-tui", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" + "mime-types": "^3.0.1" }, "devDependencies": { "@xterm/headless": "^5.5.0", @@ -6653,12 +7158,12 @@ }, "packages/web-ui": { "name": "@mariozechner/pi-web-ui", - "version": "0.30.2", + "version": "0.31.1", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-tui": "^0.30.2", + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-tui": "^0.31.1", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", @@ -6679,7 +7184,7 @@ }, "packages/web-ui/example": { "name": "pi-web-ui-example", - "version": "1.18.2", + "version": "1.19.1", "dependencies": { "@mariozechner/mini-lit": "^0.2.0", "@mariozechner/pi-ai": "file:../../ai", diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 5d20a7bb..38778e2f 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -4,6 +4,26 @@ ### Breaking Changes +- **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)): + - `steer(msg)`: Interrupts the agent mid-run. Delivered after current tool execution, skips remaining tools. + - `followUp(msg)`: Waits until the agent finishes. Delivered only when there are no more tool calls or steering messages. +- **Queue mode renamed**: `queueMode` option renamed to `steeringMode`. Added new `followUpMode` option. Both control whether messages are delivered one-at-a-time or all at once. +- **AgentLoopConfig callbacks renamed**: `getQueuedMessages` split into `getSteeringMessages` and `getFollowUpMessages`. +- **Agent methods renamed**: + - `queueMessage()` โ†’ `steer()` and `followUp()` + - `clearMessageQueue()` โ†’ `clearSteeringQueue()`, `clearFollowUpQueue()`, `clearAllQueues()` + - `setQueueMode()`/`getQueueMode()` โ†’ `setSteeringMode()`/`getSteeringMode()` and `setFollowUpMode()`/`getFollowUpMode()` + +### Fixed + +- `prompt()` and `continue()` now throw if called while the agent is already streaming, preventing race conditions and corrupted state. Use `steer()` or `followUp()` to queue messages during streaming, or `await` the previous call. + +## [0.31.1] - 2026-01-02 + +## [0.31.0] - 2026-01-02 + +### Breaking Changes + - **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations. - **Agent options renamed**: diff --git a/packages/agent/README.md b/packages/agent/README.md index 99e455c7..44206fb0 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -298,6 +298,22 @@ const readFileTool: AgentTool = { agent.setTools([readFileTool]); ``` +### Error Handling + +**Throw an error** when a tool fails. Do not return error messages as content. + +```typescript +execute: async (toolCallId, params, signal, onUpdate) => { + if (!fs.existsSync(params.path)) { + throw new Error(`File not found: ${params.path}`); + } + // Return content only on success + return { content: [{ type: "text", text: "..." }] }; +} +``` + +Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`. + ## Proxy Usage For browser apps that proxy through a backend: diff --git a/packages/agent/package.json b/packages/agent/package.json index 19c50485..d70b6d7b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-agent-core", - "version": "0.30.2", + "version": "0.31.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -17,8 +17,8 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-tui": "^0.30.2" + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-tui": "^0.31.1" }, "keywords": [ "ai", diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index ee63b89f..753466ac 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -109,71 +109,88 @@ async function runLoop( stream: EventStream, streamFn?: StreamFn, ): Promise { - let hasMoreToolCalls = true; let firstTurn = true; - let queuedMessages: AgentMessage[] = (await config.getQueuedMessages?.()) || []; - let queuedAfterTools: AgentMessage[] | null = null; + // Check for steering messages at start (user may have typed while waiting) + let pendingMessages: AgentMessage[] = (await config.getSteeringMessages?.()) || []; - while (hasMoreToolCalls || queuedMessages.length > 0) { - if (!firstTurn) { - stream.push({ type: "turn_start" }); - } else { - firstTurn = false; - } + // Outer loop: continues when queued follow-up messages arrive after agent would stop + while (true) { + let hasMoreToolCalls = true; + let steeringAfterTools: AgentMessage[] | null = null; - // Process queued messages (inject before next assistant response) - if (queuedMessages.length > 0) { - for (const message of queuedMessages) { - stream.push({ type: "message_start", message }); - stream.push({ type: "message_end", message }); - currentContext.messages.push(message); - newMessages.push(message); + // Inner loop: process tool calls and steering messages + while (hasMoreToolCalls || pendingMessages.length > 0) { + if (!firstTurn) { + stream.push({ type: "turn_start" }); + } else { + firstTurn = false; } - queuedMessages = []; - } - // Stream assistant response - const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); - newMessages.push(message); + // Process pending messages (inject before next assistant response) + if (pendingMessages.length > 0) { + for (const message of pendingMessages) { + stream.push({ type: "message_start", message }); + stream.push({ type: "message_end", message }); + currentContext.messages.push(message); + newMessages.push(message); + } + pendingMessages = []; + } - if (message.stopReason === "error" || message.stopReason === "aborted") { - stream.push({ type: "turn_end", message, toolResults: [] }); - stream.push({ type: "agent_end", messages: newMessages }); - stream.end(newMessages); - return; - } + // Stream assistant response + const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); + newMessages.push(message); - // Check for tool calls - const toolCalls = message.content.filter((c) => c.type === "toolCall"); - hasMoreToolCalls = toolCalls.length > 0; + if (message.stopReason === "error" || message.stopReason === "aborted") { + stream.push({ type: "turn_end", message, toolResults: [] }); + stream.push({ type: "agent_end", messages: newMessages }); + stream.end(newMessages); + return; + } - const toolResults: ToolResultMessage[] = []; - if (hasMoreToolCalls) { - const toolExecution = await executeToolCalls( - currentContext.tools, - message, - signal, - stream, - config.getQueuedMessages, - ); - toolResults.push(...toolExecution.toolResults); - queuedAfterTools = toolExecution.queuedMessages ?? null; + // Check for tool calls + const toolCalls = message.content.filter((c) => c.type === "toolCall"); + hasMoreToolCalls = toolCalls.length > 0; - for (const result of toolResults) { - currentContext.messages.push(result); - newMessages.push(result); + const toolResults: ToolResultMessage[] = []; + if (hasMoreToolCalls) { + const toolExecution = await executeToolCalls( + currentContext.tools, + message, + signal, + stream, + config.getSteeringMessages, + ); + toolResults.push(...toolExecution.toolResults); + steeringAfterTools = toolExecution.steeringMessages ?? null; + + for (const result of toolResults) { + currentContext.messages.push(result); + newMessages.push(result); + } + } + + stream.push({ type: "turn_end", message, toolResults }); + + // Get steering messages after turn completes + if (steeringAfterTools && steeringAfterTools.length > 0) { + pendingMessages = steeringAfterTools; + steeringAfterTools = null; + } else { + pendingMessages = (await config.getSteeringMessages?.()) || []; } } - stream.push({ type: "turn_end", message, toolResults }); - - // Get queued messages after turn completes - if (queuedAfterTools && queuedAfterTools.length > 0) { - queuedMessages = queuedAfterTools; - queuedAfterTools = null; - } else { - queuedMessages = (await config.getQueuedMessages?.()) || []; + // Agent would stop here. Check for follow-up messages. + const followUpMessages = (await config.getFollowUpMessages?.()) || []; + if (followUpMessages.length > 0) { + // Set as pending so inner loop processes them + pendingMessages = followUpMessages; + continue; } + + // No more messages, exit + break; } stream.push({ type: "agent_end", messages: newMessages }); @@ -279,11 +296,11 @@ async function executeToolCalls( assistantMessage: AssistantMessage, signal: AbortSignal | undefined, stream: EventStream, - getQueuedMessages?: AgentLoopConfig["getQueuedMessages"], -): Promise<{ toolResults: ToolResultMessage[]; queuedMessages?: AgentMessage[] }> { + getSteeringMessages?: AgentLoopConfig["getSteeringMessages"], +): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { const toolCalls = assistantMessage.content.filter((c) => c.type === "toolCall"); const results: ToolResultMessage[] = []; - let queuedMessages: AgentMessage[] | undefined; + let steeringMessages: AgentMessage[] | undefined; for (let index = 0; index < toolCalls.length; index++) { const toolCall = toolCalls[index]; @@ -343,11 +360,11 @@ async function executeToolCalls( stream.push({ type: "message_start", message: toolResultMessage }); stream.push({ type: "message_end", message: toolResultMessage }); - // Check for queued messages - skip remaining tools if user interrupted - if (getQueuedMessages) { - const queued = await getQueuedMessages(); - if (queued.length > 0) { - queuedMessages = queued; + // Check for steering messages - skip remaining tools if user interrupted + if (getSteeringMessages) { + const steering = await getSteeringMessages(); + if (steering.length > 0) { + steeringMessages = steering; const remainingCalls = toolCalls.slice(index + 1); for (const skipped of remainingCalls) { results.push(skipToolCall(skipped, stream)); @@ -357,7 +374,7 @@ async function executeToolCalls( } } - return { toolResults: results, queuedMessages }; + return { toolResults: results, steeringMessages }; } function skipToolCall( diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 078b707e..d040e9ec 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -47,9 +47,14 @@ export interface AgentOptions { transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; /** - * Queue mode: "all" = send all queued messages at once, "one-at-a-time" = one per turn + * Steering mode: "all" = send all steering messages at once, "one-at-a-time" = one per turn */ - queueMode?: "all" | "one-at-a-time"; + steeringMode?: "all" | "one-at-a-time"; + + /** + * Follow-up mode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn + */ + followUpMode?: "all" | "one-at-a-time"; /** * Custom stream function (for proxy backends, etc.). Default uses streamSimple. @@ -80,8 +85,10 @@ export class Agent { private abortController?: AbortController; private convertToLlm: (messages: AgentMessage[]) => Message[] | Promise; private transformContext?: (messages: AgentMessage[], signal?: AbortSignal) => Promise; - private messageQueue: AgentMessage[] = []; - private queueMode: "all" | "one-at-a-time"; + private steeringQueue: AgentMessage[] = []; + private followUpQueue: AgentMessage[] = []; + private steeringMode: "all" | "one-at-a-time"; + private followUpMode: "all" | "one-at-a-time"; public streamFn: StreamFn; public getApiKey?: (provider: string) => Promise | string | undefined; private runningPrompt?: Promise; @@ -91,7 +98,8 @@ export class Agent { this._state = { ...this._state, ...opts.initialState }; this.convertToLlm = opts.convertToLlm || defaultConvertToLlm; this.transformContext = opts.transformContext; - this.queueMode = opts.queueMode || "one-at-a-time"; + this.steeringMode = opts.steeringMode || "one-at-a-time"; + this.followUpMode = opts.followUpMode || "one-at-a-time"; this.streamFn = opts.streamFn || streamSimple; this.getApiKey = opts.getApiKey; } @@ -118,12 +126,20 @@ export class Agent { this._state.thinkingLevel = l; } - setQueueMode(mode: "all" | "one-at-a-time") { - this.queueMode = mode; + setSteeringMode(mode: "all" | "one-at-a-time") { + this.steeringMode = mode; } - getQueueMode(): "all" | "one-at-a-time" { - return this.queueMode; + getSteeringMode(): "all" | "one-at-a-time" { + return this.steeringMode; + } + + setFollowUpMode(mode: "all" | "one-at-a-time") { + this.followUpMode = mode; + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.followUpMode; } setTools(t: AgentTool[]) { @@ -138,12 +154,33 @@ export class Agent { this._state.messages = [...this._state.messages, m]; } - queueMessage(m: AgentMessage) { - this.messageQueue.push(m); + /** + * Queue a steering message to interrupt the agent mid-run. + * Delivered after current tool execution, skips remaining tools. + */ + steer(m: AgentMessage) { + this.steeringQueue.push(m); } - clearMessageQueue() { - this.messageQueue = []; + /** + * Queue a follow-up message to be processed after the agent finishes. + * Delivered only when agent has no more tool calls or steering messages. + */ + followUp(m: AgentMessage) { + this.followUpQueue.push(m); + } + + clearSteeringQueue() { + this.steeringQueue = []; + } + + clearFollowUpQueue() { + this.followUpQueue = []; + } + + clearAllQueues() { + this.steeringQueue = []; + this.followUpQueue = []; } clearMessages() { @@ -164,13 +201,20 @@ export class Agent { this._state.streamMessage = null; this._state.pendingToolCalls = new Set(); this._state.error = undefined; - this.messageQueue = []; + this.steeringQueue = []; + this.followUpQueue = []; } /** Send a prompt with an AgentMessage */ async prompt(message: AgentMessage | AgentMessage[]): Promise; async prompt(input: string, images?: ImageContent[]): Promise; async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]) { + if (this._state.isStreaming) { + throw new Error( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + } + const model = this._state.model; if (!model) throw new Error("No model configured"); @@ -199,6 +243,10 @@ export class Agent { /** Continue from current context (for retry after overflow) */ async continue() { + if (this._state.isStreaming) { + throw new Error("Agent is already processing. Wait for completion before continuing."); + } + const messages = this._state.messages; if (messages.length === 0) { throw new Error("No messages to continue from"); @@ -247,18 +295,32 @@ export class Agent { convertToLlm: this.convertToLlm, transformContext: this.transformContext, getApiKey: this.getApiKey, - getQueuedMessages: async () => { - if (this.queueMode === "one-at-a-time") { - if (this.messageQueue.length > 0) { - const first = this.messageQueue[0]; - this.messageQueue = this.messageQueue.slice(1); + getSteeringMessages: async () => { + if (this.steeringMode === "one-at-a-time") { + if (this.steeringQueue.length > 0) { + const first = this.steeringQueue[0]; + this.steeringQueue = this.steeringQueue.slice(1); return [first]; } return []; } else { - const queued = this.messageQueue.slice(); - this.messageQueue = []; - return queued; + const steering = this.steeringQueue.slice(); + this.steeringQueue = []; + return steering; + } + }, + getFollowUpMessages: async () => { + if (this.followUpMode === "one-at-a-time") { + if (this.followUpQueue.length > 0) { + const first = this.followUpQueue[0]; + this.followUpQueue = this.followUpQueue.slice(1); + return [first]; + } + return []; + } else { + const followUp = this.followUpQueue.slice(); + this.followUpQueue = []; + return followUp; } }, }; diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index e8af618e..b7ea7f7c 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -75,12 +75,26 @@ export interface AgentLoopConfig extends SimpleStreamOptions { getApiKey?: (provider: string) => Promise | string | undefined; /** - * Returns queued messages to inject into the conversation. + * Returns steering messages to inject into the conversation mid-run. * - * Called after each turn to check for user interruptions or injected messages. - * If messages are returned, they're added to the context before the next LLM call. + * Called after each tool execution to check for user interruptions. + * If messages are returned, remaining tool calls are skipped and + * these messages are added to the context before the next LLM call. + * + * Use this for "steering" the agent while it's working. */ - getQueuedMessages?: () => Promise; + getSteeringMessages?: () => Promise; + + /** + * Returns follow-up messages to process after the agent would otherwise stop. + * + * Called when the agent has no more tool calls and no steering messages. + * If messages are returned, they're added to the context and the agent + * continues with another turn. + * + * Use this for follow-up messages that should wait until the agent finishes. + */ + getFollowUpMessages?: () => Promise; } /** diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index b8295038..c1ee890c 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -340,8 +340,8 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, - getQueuedMessages: async () => { - // Return queued message after first tool executes + getSteeringMessages: async () => { + // Return steering message after first tool executes if (executed.length === 1 && !queuedDelivered) { queuedDelivered = true; return [queuedUserMessage]; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 8fee033f..3332d16e 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1,7 +1,41 @@ -import { getModel } from "@mariozechner/pi-ai"; +import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai"; import { describe, expect, it } from "vitest"; import { Agent } from "../src/index.js"; +// Mock stream that mimics AssistantMessageEventStream +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createAssistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "openai-responses", + provider: "openai", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + describe("Agent", () => { it("should create an agent instance with default state", () => { const agent = new Agent(); @@ -93,11 +127,21 @@ describe("Agent", () => { expect(agent.state.messages).toEqual([]); }); - it("should support message queueing", async () => { + it("should support steering message queue", async () => { const agent = new Agent(); - const message = { role: "user" as const, content: "Queued message", timestamp: Date.now() }; - agent.queueMessage(message); + const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() }; + agent.steer(message); + + // The message is queued but not yet in state.messages + expect(agent.state.messages).not.toContainEqual(message); + }); + + it("should support follow-up message queue", async () => { + const agent = new Agent(); + + const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() }; + agent.followUp(message); // The message is queued but not yet in state.messages expect(agent.state.messages).not.toContainEqual(message); @@ -109,4 +153,80 @@ describe("Agent", () => { // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); }); + + it("should throw when prompt() called while streaming", async () => { + let abortSignal: AbortSignal | undefined; + const agent = new Agent({ + // Use a stream function that responds to abort + streamFn: (_model, _context, options) => { + abortSignal = options?.signal; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + // Check abort signal periodically + const checkAbort = () => { + if (abortSignal?.aborted) { + stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); + } else { + setTimeout(checkAbort, 5); + } + }; + checkAbort(); + }); + return stream; + }, + }); + + // Start first prompt (don't await, it will block until abort) + const firstPrompt = agent.prompt("First message"); + + // Wait a tick for isStreaming to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(agent.state.isStreaming).toBe(true); + + // Second prompt should reject + await expect(agent.prompt("Second message")).rejects.toThrow( + "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", + ); + + // Cleanup - abort to stop the stream + agent.abort(); + await firstPrompt.catch(() => {}); // Ignore abort error + }); + + it("should throw when continue() called while streaming", async () => { + let abortSignal: AbortSignal | undefined; + const agent = new Agent({ + streamFn: (_model, _context, options) => { + abortSignal = options?.signal; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + const checkAbort = () => { + if (abortSignal?.aborted) { + stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); + } else { + setTimeout(checkAbort, 5); + } + }; + checkAbort(); + }); + return stream; + }, + }); + + // Start first prompt + const firstPrompt = agent.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(agent.state.isStreaming).toBe(true); + + // continue() should reject + await expect(agent.continue()).rejects.toThrow( + "Agent is already processing. Wait for completion before continuing.", + ); + + // Cleanup + agent.abort(); + await firstPrompt.catch(() => {}); + }); }); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6d822a71..88e15624 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- **Gemini CLI rate limit handling**: Added automatic retry with server-provided delay for 429 errors. Parses delay from error messages like "Your quota will reset after 39s" and waits accordingly. Falls back to exponential backoff for other transient errors. ([#370](https://github.com/badlogic/pi-mono/issues/370)) + +## [0.31.1] - 2026-01-02 + +## [0.31.0] - 2026-01-02 + ### Breaking Changes - **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. Import from that package instead of `@mariozechner/pi-ai`. diff --git a/packages/ai/package.json b/packages/ai/package.json index d8d13ce0..77b01b42 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-ai", - "version": "0.30.2", + "version": "0.31.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index f7fb6aff..b4c7d35c 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -3036,8 +3036,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.056, - output: 0.224, + input: 0.07, + output: 0.28, cacheRead: 0, cacheWrite: 0, }, @@ -3053,8 +3053,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.112, - output: 0.448, + input: 0.14, + output: 0.56, cacheRead: 0, cacheWrite: 0, }, @@ -3263,7 +3263,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 163840, + maxTokens: 65536, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-distill-llama-70b": { id: "deepseek/deepseek-r1-distill-llama-70b", @@ -3563,13 +3563,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.04, - output: 0.15, + input: 0.036, + output: 0.064, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 96000, - maxTokens: 96000, + contextWindow: 131072, + maxTokens: 4096, } satisfies Model<"openai-completions">, "google/gemma-3-27b-it:free": { id: "google/gemma-3-27b-it:free", @@ -5297,8 +5297,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.039, - output: 0.19, + input: 0.02, + output: 0.09999999999999999, cacheRead: 0, cacheWrite: 0, }, @@ -5348,8 +5348,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.03, - output: 0.14, + input: 0.016, + output: 0.06, cacheRead: 0, cacheWrite: 0, }, @@ -5858,8 +5858,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.028, - output: 0.1104, + input: 0.035, + output: 0.13799999999999998, cacheRead: 0, cacheWrite: 0, }, @@ -5994,8 +5994,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, - output: 1.1, + input: 0.06, + output: 0.6, cacheRead: 0, cacheWrite: 0, }, @@ -6011,13 +6011,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.12, + input: 0.15, output: 1.2, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 32768, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-235b-a22b-instruct": { id: "qwen/qwen3-vl-235b-a22b-instruct", @@ -6079,8 +6079,8 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.16, - output: 0.7999999999999999, + input: 0.19999999999999998, + output: 1, cacheRead: 0, cacheWrite: 0, }, @@ -6096,8 +6096,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.064, - output: 0.39999999999999997, + input: 0.08, + output: 0.5, cacheRead: 0, cacheWrite: 0, }, @@ -6487,8 +6487,8 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.10400000000000001, - output: 0.6799999999999999, + input: 0.13, + output: 0.85, cacheRead: 0, cacheWrite: 0, }, @@ -6521,9 +6521,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.48, - output: 1.44, - cacheRead: 0.088, + input: 0.6, + output: 1.7999999999999998, + cacheRead: 0.11, cacheWrite: 0, }, contextWindow: 65536, diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 85d644dd..7bc8ef9a 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -72,6 +72,83 @@ const ANTIGRAVITY_HEADERS = { // Counter for generating unique tool call IDs let toolCallCounter = 0; +// Retry configuration +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 1000; + +/** + * Extract retry delay from Gemini error response (in milliseconds). + * Parses patterns like: + * - "Your quota will reset after 39s" + * - "Your quota will reset after 18h31m10s" + * - "Please retry in Xs" or "Please retry in Xms" + * - "retryDelay": "34.074824224s" (JSON field) + */ +function extractRetryDelay(errorText: string): number | undefined { + // Pattern 1: "Your quota will reset after ..." (formats: "18h31m10s", "10m15s", "6s", "39s") + const durationMatch = errorText.match(/reset after (?:(\d+)h)?(?:(\d+)m)?(\d+(?:\.\d+)?)s/i); + if (durationMatch) { + const hours = durationMatch[1] ? parseInt(durationMatch[1], 10) : 0; + const minutes = durationMatch[2] ? parseInt(durationMatch[2], 10) : 0; + const seconds = parseFloat(durationMatch[3]); + if (!Number.isNaN(seconds)) { + const totalMs = ((hours * 60 + minutes) * 60 + seconds) * 1000; + if (totalMs > 0) { + return Math.ceil(totalMs + 1000); // Add 1s buffer + } + } + } + + // Pattern 2: "Please retry in X[ms|s]" + const retryInMatch = errorText.match(/Please retry in ([0-9.]+)(ms|s)/i); + if (retryInMatch?.[1]) { + const value = parseFloat(retryInMatch[1]); + if (!Number.isNaN(value) && value > 0) { + const ms = retryInMatch[2].toLowerCase() === "ms" ? value : value * 1000; + return Math.ceil(ms + 1000); + } + } + + // Pattern 3: "retryDelay": "34.074824224s" (JSON field in error details) + const retryDelayMatch = errorText.match(/"retryDelay":\s*"([0-9.]+)(ms|s)"/i); + if (retryDelayMatch?.[1]) { + const value = parseFloat(retryDelayMatch[1]); + if (!Number.isNaN(value) && value > 0) { + const ms = retryDelayMatch[2].toLowerCase() === "ms" ? value : value * 1000; + return Math.ceil(ms + 1000); + } + } + + return undefined; +} + +/** + * Check if an error is retryable (rate limit, server error, etc.) + */ +function isRetryableError(status: number, errorText: string): boolean { + if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { + return true; + } + return /resource.?exhausted|rate.?limit|overloaded|service.?unavailable/i.test(errorText); +} + +/** + * Sleep for a given number of milliseconds, respecting abort signal. + */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Request was aborted")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timeout); + reject(new Error("Request was aborted")); + }); + }); +} + interface CloudCodeAssistRequest { project: string; model: string; @@ -181,21 +258,62 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( const isAntigravity = endpoint.includes("sandbox.googleapis.com"); const headers = isAntigravity ? ANTIGRAVITY_HEADERS : GEMINI_CLI_HEADERS; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - Accept: "text/event-stream", - ...headers, - }, - body: JSON.stringify(requestBody), - signal: options?.signal, - }); + // Fetch with retry logic for rate limits and transient errors + let response: Response | undefined; + let lastError: Error | undefined; - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`); + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (options?.signal?.aborted) { + throw new Error("Request was aborted"); + } + + try { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + Accept: "text/event-stream", + ...headers, + }, + body: JSON.stringify(requestBody), + signal: options?.signal, + }); + + if (response.ok) { + break; // Success, exit retry loop + } + + const errorText = await response.text(); + + // Check if retryable + if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { + // Use server-provided delay or exponential backoff + const serverDelay = extractRetryDelay(errorText); + const delayMs = serverDelay ?? BASE_DELAY_MS * 2 ** attempt; + await sleep(delayMs, options?.signal); + continue; + } + + // Not retryable or max retries exceeded + throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`); + } catch (error) { + if (error instanceof Error && error.message === "Request was aborted") { + throw error; + } + lastError = error instanceof Error ? error : new Error(String(error)); + // Network errors are retryable + if (attempt < MAX_RETRIES) { + const delayMs = BASE_DELAY_MS * 2 ** attempt; + await sleep(delayMs, options?.signal); + continue; + } + throw lastError; + } + } + + if (!response || !response.ok) { + throw lastError ?? new Error("Failed to get response after retries"); } if (!response.body) { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c7f0aa00..f3edcd73 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,99 +4,289 @@ ### Breaking Changes -- **Session tree structure (v2)**: Sessions now store entries as a tree with `id`/`parentId` fields, enabling in-place branching without creating new files. Existing v1 sessions are auto-migrated on load. -- **SessionManager API**: - - `saveXXX()` renamed to `appendXXX()` (e.g., `appendMessage`, `appendCompaction`) - - `branchInPlace()` renamed to `branch()` - - `reset()` renamed to `newSession()` - - `createBranchedSessionFromEntries(entries, index)` replaced with `createBranchedSession(leafId)` - - `saveCompaction(entry)` replaced with `appendCompaction(summary, firstKeptEntryId, tokensBefore)` - - `getEntries()` now excludes the session header (use `getHeader()` separately) - - New methods: `getTree()`, `getPath()`, `getLeafUuid()`, `getLeafEntry()`, `getEntry()`, `branchWithSummary()` - - New `appendCustomEntry(customType, data)` for hooks to store custom data (not in LLM context) - - New `appendCustomMessageEntry(customType, content, display, details?)` for hooks to inject messages into LLM context -- **Compaction API**: - - `CompactionEntry` and `CompactionResult` are now generic with optional `details?: T` for hook-specific data - - `compact()` now returns `CompactionResult` (`{ summary, firstKeptEntryId, tokensBefore, details? }`) instead of `CompactionEntry` - - `appendCompaction()` now accepts optional `details` parameter - - `CompactionEntry.firstKeptEntryIndex` replaced with `firstKeptEntryId` - - `prepareCompaction(pathEntries, settings)` now takes path entries (from `getPath()`) and settings only - - `CompactionPreparation` restructured: removed `cutPoint`, `messagesToKeep`, `boundaryStart`; added `turnPrefixMessages`, `isSplitTurn`, `previousSummary`, `fileOps`, `settings` - - `compact(preparation, model, apiKey, customInstructions?, signal?)` now takes preparation and execution context separately -- **Hook types**: - - `HookEventContext` renamed to `HookContext` - - `HookContext` now has `sessionManager`, `modelRegistry`, and `model` (current model, may be undefined) - - `HookCommandContext` removed - `RegisteredCommand.handler` now takes `(args: string, ctx: HookContext)` - - `before_compact` event: removed `previousCompactions` and `model`, added `branchEntries: SessionEntry[]` (hooks extract what they need) - - `before_tree` event: removed `model` (use `ctx.model` instead) - - `before_switch` event now has `targetSessionFile`, `switch` event has `previousSessionFile` - - Removed `resolveApiKey` (use `modelRegistry.getApiKey(model)`) - - Hooks can return `compaction.details` to store custom data (e.g., ArtifactIndex for structured compaction) -- **Hook API**: - - `pi.send(text, attachments?)` replaced with `pi.sendMessage(message, triggerTurn?)` which creates `CustomMessageEntry` instead of user messages - - New `pi.appendEntry(customType, data?)` to persist hook state (does NOT participate in LLM context) - - New `pi.registerCommand(name, options)` to register custom slash commands - - New `pi.registerMessageRenderer(customType, renderer)` to register custom renderers for hook messages - - New `pi.exec(command, args, options?)` to execute shell commands (moved from `HookEventContext`/`HookCommandContext`) - - `HookMessageRenderer` type: `(message: HookMessage, options, theme) => Component | null` - - Renderers return inner content; the TUI wraps it in a styled Box - - New types: `HookMessage`, `RegisteredCommand`, `HookContext` - - Handler types renamed: `SendHandler` โ†’ `SendMessageHandler`, new `AppendEntryHandler` -- **SessionManager**: - - `getSessionFile()` now returns `string | undefined` (undefined for in-memory sessions) -- **Themes**: Custom themes must add `selectedBg`, `customMessageBg`, `customMessageText`, `customMessageLabel` color tokens (50 total) -- **Custom tools**: `dispose()` method removed from `CustomAgentTool`. Use `onSession` with `reason: "shutdown"` instead for cleanup. `SessionEvent.reason` now includes `"shutdown"`. -- **Renamed exports**: - - `messageTransformer` โ†’ `convertToLlm` - - `SessionContext` alias `LoadedSession` removed (use `SessionContext` directly) -- **Removed exports**: - - `createSummaryMessage()` - replaced by internal compaction logic - - `SUMMARY_PREFIX`, `SUMMARY_SUFFIX` - no longer used - - `Attachment` type - use `ImageContent` from `@mariozechner/pi-ai` instead -- **New exports**: - - `BranchSummaryEntry`, `CustomEntry`, `CustomMessageEntry`, `LabelEntry` - new session entry types - - `SessionEntryBase`, `FileEntry` - base types for session entries - - `CURRENT_SESSION_VERSION`, `migrateSessionEntries` - session migration utilities - - `BranchPreparation`, `BranchSummaryResult`, `CollectEntriesResult`, `GenerateBranchSummaryOptions` - branch summarization types - - `FileOperations`, `collectEntriesForBranchSummary`, `prepareBranchEntries`, `generateBranchSummary` - branch summarization utilities - - `CompactionPreparation`, `CompactionDetails` - compaction preparation types - - `ReadonlySessionManager` - read-only session manager interface for hooks - - `HookMessage`, `HookContext`, `HookMessageRenderOptions` - hook types - - `isHookMessage`, `createHookMessage` - hook message utilities +- **Queue API replaced with steer/followUp**: The `queueMessage()` method has been split into two methods with different delivery semantics ([#403](https://github.com/badlogic/pi-mono/issues/403)): + - `steer(text)`: Interrupts the agent mid-run (Enter while streaming). Delivered after current tool execution. + - `followUp(text)`: Waits until the agent finishes (Alt+Enter while streaming). Delivered only when agent stops. +- **Settings renamed**: `queueMode` setting renamed to `steeringMode`. Added new `followUpMode` setting. Old settings.json files are migrated automatically. +- **AgentSession methods renamed**: + - `queueMessage()` โ†’ `steer()` and `followUp()` + - `queueMode` getter โ†’ `steeringMode` and `followUpMode` getters + - `setQueueMode()` โ†’ `setSteeringMode()` and `setFollowUpMode()` + - `queuedMessageCount` โ†’ `pendingMessageCount` + - `getQueuedMessages()` โ†’ `getSteeringMessages()` and `getFollowUpMessages()` + - `clearQueue()` now returns `{ steering: string[], followUp: string[] }` + - `hasQueuedMessages()` โ†’ `hasPendingMessages()` +- **Hook API signature changed**: `pi.sendMessage()` second parameter changed from `triggerTurn?: boolean` to `options?: { triggerTurn?, deliverAs? }`. Use `deliverAs: "followUp"` for follow-up delivery. Affects both hooks and internal `sendHookMessage()` method. +- **RPC API changes**: + - `queue_message` command โ†’ `steer` and `follow_up` commands + - `set_queue_mode` command โ†’ `set_steering_mode` and `set_follow_up_mode` commands + - `RpcSessionState.queueMode` โ†’ `steeringMode` and `followUpMode` +- **Settings UI**: "Queue mode" setting split into "Steering mode" and "Follow-up mode" ### Added -- **`/tree` command**: Navigate the session tree in-place. Shows full tree structure with labels, supports search (type to filter), page navigation (โ†/โ†’), and filter modes (Ctrl+O cycles: default โ†’ no-tools โ†’ user-only โ†’ labeled-only โ†’ all, Shift+Ctrl+O cycles backwards). Selecting a branch generates a summary and switches context. Press `l` to label entries. -- **`context` hook event**: Fires before each LLM call, allowing hooks to non-destructively modify messages. Returns `{ messages }` to override. Useful for dynamic context pruning without modifying session history. -- **`before_agent_start` hook event**: Fires once when user submits a prompt, before `agent_start`. Hooks can return `{ message }` to inject a `CustomMessageEntry` that gets persisted and sent to the LLM. -- **`ui.custom()` for hooks**: Show arbitrary TUI components with keyboard focus. Call `done()` when finished: `ctx.ui.custom(component, done)`. -- **Branch summarization**: When switching branches via `/tree`, generates a summary of the abandoned branch including file operations (read/modified files). Summaries are stored as `BranchSummaryEntry` with cumulative file tracking in `details`. -- **Structured compaction**: Both compaction and branch summarization now use structured output format with clear sections (Goal, Progress, Key Information, File Operations). Conversations are serialized to text before summarization to prevent the model from "continuing" conversations. -- **File tracking in summaries**: Compaction and branch summaries now track `readFiles` and `modifiedFiles` arrays in the `details` field, accumulated across multiple compactions/summaries. This provides cumulative file operation history. -- **`selectedBg` theme color**: Background color for selected/active lines in tree selector and other components. -- **Entry labels**: Label any session entry with `/tree` โ†’ select entry โ†’ press `l`. Labels appear in tree view and are persisted as `LabelEntry` in the session. Use `labeled-only` filter mode to show only labeled entries. -- **`enabledModels` setting**: Configure whitelisted models in `settings.json` (same format as `--models` CLI flag). CLI `--models` takes precedence over the setting. -- **Snake game example hook**: Added `examples/hooks/snake.ts` demonstrating `ui.custom()`, `registerCommand()`, and session persistence. +- Automatic image resizing: images larger than 2000x2000 are resized for better model compatibility. Original dimensions are injected into the prompt. Controlled via `/settings` or `images.autoResize` in settings.json. ([#402](https://github.com/badlogic/pi-mono/pull/402) by [@mitsuhiko](https://github.com/mitsuhiko)) +- Alt+Enter keybind to queue follow-up messages while agent is streaming +- `Theme` and `ThemeColor` types now exported for hooks using `ctx.ui.custom()` +- Terminal window title now displays "pi - dirname" to identify which project session you're in ([#407](https://github.com/badlogic/pi-mono/pull/407) by [@kaofelix](https://github.com/kaofelix)) + +### Fixed + +- Shift+Space, Shift+Backspace, and Shift+Delete now work correctly in Kitty-protocol terminals (Kitty, WezTerm, etc.) instead of being silently ignored ([#411](https://github.com/badlogic/pi-mono/pull/411) by [@nathyong](https://github.com/nathyong)) +- `AgentSession.prompt()` now throws if called while the agent is already streaming, preventing race conditions. Use `steer()` or `followUp()` to queue messages during streaming. +- Ctrl+C now works like Escape in selector components, so mashing Ctrl+C will eventually close the program ([#400](https://github.com/badlogic/pi-mono/pull/400) by [@mitsuhiko](https://github.com/mitsuhiko)) + +## [0.31.1] - 2026-01-02 + +### Fixed + +- Model selector no longer allows negative index when pressing arrow keys before models finish loading ([#398](https://github.com/badlogic/pi-mono/pull/398) by [@mitsuhiko](https://github.com/mitsuhiko)) +- Type guard functions (`isBashToolResult`, etc.) now exported at runtime, not just in type declarations ([#397](https://github.com/badlogic/pi-mono/issues/397)) + +## [0.31.0] - 2026-01-02 + +This release introduces session trees for in-place branching, major API changes to hooks and custom tools, and structured compaction with file tracking. + +### Session Tree + +Sessions now use a tree structure with `id`/`parentId` fields. This enables in-place branching: navigate to any previous point with `/tree`, continue from there, and switch between branches while preserving all history in a single file. + +**Existing sessions are automatically migrated** (v1 โ†’ v2) on first load. No manual action required. + +New entry types: `BranchSummaryEntry` (context from abandoned branches), `CustomEntry` (hook state), `CustomMessageEntry` (hook-injected messages), `LabelEntry` (bookmarks). + +See [docs/session.md](docs/session.md) for the file format and `SessionManager` API. + +### Hooks Migration + +The hooks API has been restructured with more granular events and better session access. + +**Type renames:** +- `HookEventContext` โ†’ `HookContext` +- `HookCommandContext` is now a new interface extending `HookContext` with session control methods + +**Event changes:** +- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown` +- `session_before_switch` and `session_switch` events now include `reason: "new" | "resume"` to distinguish between `/new` and `/resume` +- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary) +- New `before_agent_start` event: inject messages before the agent loop starts +- New `context` event: modify messages non-destructively before each LLM call +- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead + +**API changes:** +- `pi.send(text, attachments?)` โ†’ `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`) +- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context) +- New `pi.registerCommand(name, options)` for custom slash commands (handler receives `HookCommandContext`) +- New `pi.registerMessageRenderer(customType, renderer)` for custom TUI rendering +- New `ctx.isIdle()`, `ctx.abort()`, `ctx.hasQueuedMessages()` for agent state (available in all events) +- New `ctx.ui.editor(title, prefill?)` for multi-line text editing with Ctrl+G external editor support +- New `ctx.ui.custom(component)` for full TUI component rendering with keyboard focus +- New `ctx.ui.setStatus(key, text)` for persistent status text in footer (multiple hooks can set their own) +- New `ctx.ui.theme` getter for styling text with theme colors +- `ctx.exec()` moved to `pi.exec()` +- `ctx.sessionFile` โ†’ `ctx.sessionManager.getSessionFile()` +- New `ctx.modelRegistry` and `ctx.model` for API key resolution + +**HookCommandContext (slash commands only):** +- `ctx.waitForIdle()` - wait for agent to finish streaming +- `ctx.newSession(options?)` - create new sessions with optional setup callback +- `ctx.branch(entryId)` - branch from a specific entry +- `ctx.navigateTree(targetId, options?)` - navigate the session tree + +These methods are only on `HookCommandContext` (not `HookContext`) because they can deadlock if called from event handlers that run inside the agent loop. + +**Removed:** +- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort) +- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`) + +See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for the current API. + +### Custom Tools Migration + +The custom tools API has been restructured to mirror the hooks pattern with a context object. + +**Type renames:** +- `CustomAgentTool` โ†’ `CustomTool` +- `ToolAPI` โ†’ `CustomToolAPI` +- `ToolContext` โ†’ `CustomToolContext` +- `ToolSessionEvent` โ†’ `CustomToolSessionEvent` + +**Execute signature changed:** +```typescript +// Before (v0.30.2) +execute(toolCallId, params, signal, onUpdate) + +// After +execute(toolCallId, params, onUpdate, ctx, signal?) +``` + +The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, `model`, and agent state methods: +- `ctx.isIdle()` - check if agent is streaming +- `ctx.hasQueuedMessages()` - check if user has queued messages (skip interactive prompts) +- `ctx.abort()` - abort current operation (fire-and-forget) + +**Session event changes:** +- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile` +- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state +- Reasons: `"start" | "switch" | "branch" | "tree" | "shutdown"` (no separate `"new"` reason; `/new` triggers `"switch"`) +- `dispose()` method removed. Use `onSession` with `reason: "shutdown"` for cleanup + +See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](examples/custom-tools/) for the current API. + +### SDK Migration + +**Type changes:** +- `CustomAgentTool` โ†’ `CustomTool` +- `AppMessage` โ†’ `AgentMessage` +- `sessionFile` returns `string | undefined` (was `string | null`) +- `model` returns `Model | undefined` (was `Model | null`) +- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays. + +**AgentSession API:** +- `branch(entryIndex: number)` โ†’ `branch(entryId: string)` +- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }` +- `reset()` โ†’ `newSession(options?)` where options has optional `parentSession` for lineage tracking +- `newSession()` and `switchSession()` now return `Promise` (false if cancelled by hook) +- New `navigateTree(targetId, options?)` for in-place tree navigation + +**Hook integration:** +- New `sendHookMessage(message, triggerTurn?)` for hook message injection + +**SessionManager API:** +- Method renames: `saveXXX()` โ†’ `appendXXX()` (e.g., `appendMessage`, `appendCompaction`) +- `branchInPlace()` โ†’ `branch()` +- `reset()` โ†’ `newSession(options?)` with optional `parentSession` for lineage tracking +- `createBranchedSessionFromEntries(entries, index)` โ†’ `createBranchedSession(leafId)` +- `SessionHeader.branchedFrom` โ†’ `SessionHeader.parentSession` +- `saveCompaction(entry)` โ†’ `appendCompaction(summary, firstKeptEntryId, tokensBefore, details?)` +- `getEntries()` now excludes the session header (use `getHeader()` separately) +- `getSessionFile()` returns `string | undefined` (undefined for in-memory sessions) +- New tree methods: `getTree()`, `getBranch()`, `getLeafId()`, `getLeafEntry()`, `getEntry()`, `getChildren()`, `getLabel()` +- New append methods: `appendCustomEntry()`, `appendCustomMessageEntry()`, `appendLabelChange()` +- New branch methods: `branch(entryId)`, `branchWithSummary()` + +**ModelRegistry (new):** + +`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`. + +```typescript +import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent"; + +const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json +const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json + +// Get all models (built-in + custom) +const allModels = modelRegistry.getAll(); + +// Get only models with valid API keys +const available = await modelRegistry.getAvailable(); + +// Find specific model +const model = modelRegistry.find("anthropic", "claude-sonnet-4-20250514"); + +// Get API key for a model +const apiKey = await modelRegistry.getApiKey(model); +``` + +This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`. + +**Renamed exports:** +- `messageTransformer` โ†’ `convertToLlm` +- `SessionContext` alias `LoadedSession` removed + +See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the current API. + +### RPC Migration + +**Session commands:** +- `reset` command โ†’ `new_session` command with optional `parentSession` field + +**Branching commands:** +- `branch` command: `entryIndex` โ†’ `entryId` +- `get_branch_messages` response: `entryIndex` โ†’ `entryId` + +**Type changes:** +- Messages are now `AgentMessage` (was `AppMessage`) +- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format + +**Compaction events:** +- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`) +- `auto_compaction_end` now includes `willRetry` field +- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`) + +See [docs/rpc.md](docs/rpc.md) for the current protocol. + +### Structured Compaction + +Compaction and branch summarization now use a structured output format: +- Clear sections: Goal, Progress, Key Information, File Operations +- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions +- Conversations are serialized to text before summarization to prevent the model from "continuing" them + +The `before_compact` and `before_tree` hook events allow custom compaction implementations. See [docs/compaction.md](docs/compaction.md). + +### Interactive Mode + +**`/tree` command:** +- Navigate the full session tree in-place +- Search by typing, page with โ†/โ†’ +- Filter modes (Ctrl+O): default โ†’ no-tools โ†’ user-only โ†’ labeled-only โ†’ all +- Press `l` to label entries as bookmarks +- Selecting a branch switches context and optionally injects a summary of the abandoned branch + +**Entry labels:** +- Bookmark any entry via `/tree` โ†’ select โ†’ `l` +- Labels appear in tree view and persist as `LabelEntry` + +**Theme changes (breaking for custom themes):** + +Custom themes must add these new color tokens or they will fail to load: +- `selectedBg`: background for selected/highlighted items in tree selector and other components +- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`) +- `customMessageText`: text color for hook messages +- `customMessageLabel`: label color for hook messages (the `[customType]` prefix) + +Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes. + +**Settings:** +- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI) + +### Added + +- `ctx.ui.setStatus(key, text)` for hooks to display persistent status text in the footer ([#385](https://github.com/badlogic/pi-mono/pull/385) by [@prateekmedia](https://github.com/prateekmedia)) +- `ctx.ui.theme` getter for styling status text and other output with theme colors +- `/share` command to upload session as a secret GitHub gist and get a shareable URL via shittycodingagent.ai ([#380](https://github.com/badlogic/pi-mono/issues/380)) +- HTML export now includes a tree visualization sidebar for navigating session branches ([#375](https://github.com/badlogic/pi-mono/issues/375)) +- HTML export supports keyboard shortcuts: Ctrl+T to toggle thinking blocks, Ctrl+O to toggle tool outputs +- HTML export supports theme-configurable background colors via optional `export` section in theme JSON ([#387](https://github.com/badlogic/pi-mono/pull/387) by [@mitsuhiko](https://github.com/mitsuhiko)) +- HTML export syntax highlighting now uses theme colors and matches TUI rendering +- **Snake game example hook**: Demonstrates `ui.custom()`, `registerCommand()`, and session persistence. See [examples/hooks/snake.ts](examples/hooks/snake.ts). +- **`thinkingText` theme token**: Configurable color for thinking block text. ([#366](https://github.com/badlogic/pi-mono/pull/366) by [@paulbettner](https://github.com/paulbettner)) ### Changed - **Entry IDs**: Session entries now use short 8-character hex IDs instead of full UUIDs - **API key priority**: `ANTHROPIC_OAUTH_TOKEN` now takes precedence over `ANTHROPIC_API_KEY` -- **New entry types**: `BranchSummaryEntry` for branch context, `CustomEntry` for hook state persistence, `CustomMessageEntry` for hook-injected context messages, `LabelEntry` for user-defined bookmarks -- **TUI**: `CustomMessageEntry` renders with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors). Entries with `display: false` are hidden. -- **AgentSession**: New `sendHookMessage(message, triggerTurn?)` method for hooks to inject messages. Handles queuing during streaming, direct append when idle, and optional turn triggering. -- **HookMessage**: New message type with `role: "hookMessage"` for hook-injected messages in agent events. Use `isHookMessage(msg)` type guard to identify them. These are converted to user messages for LLM context via `messageTransformer`. -- **Agent.prompt()**: Now accepts `AppMessage` directly (in addition to `string, attachments?`) for custom message types like `HookMessage`. +- HTML export template split into separate files (template.html, template.css, template.js) for easier maintenance ### Fixed +- HTML export now properly sanitizes user messages containing HTML tags like ` - - -
-
-

${APP_NAME} v${VERSION}

-
-
Session:${escapeHtml(data.sessionId)}
-
Date:${new Date(data.timestamp).toLocaleString()}
-
Models:${ - Array.from(data.modelsUsed) - .map((m) => escapeHtml(m)) - .join(", ") || "unknown" - }
-
-
- -
-

Messages

-
-
User:${userMessages}
-
Assistant:${assistantMessages}
-
Tool Calls:${toolCallsCount}
-
-
- -
-

Tokens & Cost

-
-
Input:${data.tokenStats.input.toLocaleString()} tokens
-
Output:${data.tokenStats.output.toLocaleString()} tokens
-
Cache Read:${data.tokenStats.cacheRead.toLocaleString()} tokens
-
Cache Write:${data.tokenStats.cacheWrite.toLocaleString()} tokens
-
Total:${(data.tokenStats.input + data.tokenStats.output + data.tokenStats.cacheRead + data.tokenStats.cacheWrite).toLocaleString()} tokens
-
Input Cost:$${data.costStats.input.toFixed(4)}
-
Output Cost:$${data.costStats.output.toFixed(4)}
-
Cache Read Cost:$${data.costStats.cacheRead.toFixed(4)}
-
Cache Write Cost:$${data.costStats.cacheWrite.toFixed(4)}
-
Total Cost:$${(data.costStats.input + data.costStats.output + data.costStats.cacheRead + data.costStats.cacheWrite).toFixed(4)}
-
Context Usage:${contextUsageText}
-
-
- - ${systemPromptHtml} - ${toolsHtml} - ${streamingNotice} - -
- ${messagesHtml} -
- - -
- -`; -} - -// ============================================================================ -// Public API -// ============================================================================ - -export interface ExportOptions { - outputPath?: string; - themeName?: string; -} - -/** - * Export session to HTML using SessionManager and AgentState. - * Used by TUI's /export command. - * @param sessionManager The session manager - * @param state The agent state - * @param options Export options including output path and theme name - */ -export function exportSessionToHtml( - sessionManager: SessionManager, - state: AgentState, - options?: ExportOptions | string, -): string { - // Handle backwards compatibility: options can be just the output path string - const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; - - const sessionFile = sessionManager.getSessionFile(); - if (!sessionFile) { - throw new Error("Cannot export in-memory session to HTML"); - } - const content = readFileSync(sessionFile, "utf8"); - const data = parseSessionFile(content); - - // Enrich with data from AgentState (tools, context window) - data.tools = state.tools.map((t: { name: string; description: string }) => ({ - name: t.name, - description: t.description, - })); - data.contextWindow = state.model?.contextWindow; - if (!data.systemPrompt) { - data.systemPrompt = state.systemPrompt; - } - - let outputPath = opts.outputPath; - if (!outputPath) { - const sessionBasename = basename(sessionFile, ".jsonl"); - outputPath = `${APP_NAME}-session-${sessionBasename}.html`; - } - - const colors = getThemeColors(opts.themeName); - const isLight = isLightTheme(opts.themeName); - const html = generateHtml(data, basename(sessionFile), colors, isLight); - writeFileSync(outputPath, html, "utf8"); - return outputPath; -} - -/** - * Export session file to HTML (standalone, without AgentState). - * Auto-detects format: session manager format or streaming event format. - * Used by CLI for exporting arbitrary session files. - * @param inputPath Path to the session file - * @param options Export options including output path and theme name - */ -export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { - // Handle backwards compatibility: options can be just the output path string - const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; - - if (!existsSync(inputPath)) { - throw new Error(`File not found: ${inputPath}`); - } - - const content = readFileSync(inputPath, "utf8"); - const data = parseSessionFile(content); - - let outputPath = opts.outputPath; - if (!outputPath) { - const inputBasename = basename(inputPath, ".jsonl"); - outputPath = `${APP_NAME}-session-${inputBasename}.html`; - } - - const colors = getThemeColors(opts.themeName); - const isLight = isLightTheme(opts.themeName); - const html = generateHtml(data, basename(inputPath), colors, isLight); - writeFileSync(outputPath, html, "utf8"); - return outputPath; -} diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts new file mode 100644 index 00000000..577f262c --- /dev/null +++ b/packages/coding-agent/src/core/export-html/index.ts @@ -0,0 +1,211 @@ +import type { AgentState } from "@mariozechner/pi-agent-core"; +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; +import { APP_NAME, getExportTemplateDir } from "../../config.js"; +import { getResolvedThemeColors, getThemeExportColors } from "../../modes/interactive/theme/theme.js"; +import { SessionManager } from "../session-manager.js"; + +export interface ExportOptions { + outputPath?: string; + themeName?: string; +} + +/** Parse a color string to RGB values. Supports hex (#RRGGBB) and rgb(r,g,b) formats. */ +function parseColor(color: string): { r: number; g: number; b: number } | undefined { + const hexMatch = color.match(/^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/); + if (hexMatch) { + return { + r: Number.parseInt(hexMatch[1], 16), + g: Number.parseInt(hexMatch[2], 16), + b: Number.parseInt(hexMatch[3], 16), + }; + } + const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgbMatch) { + return { + r: Number.parseInt(rgbMatch[1], 10), + g: Number.parseInt(rgbMatch[2], 10), + b: Number.parseInt(rgbMatch[3], 10), + }; + } + return undefined; +} + +/** Calculate relative luminance of a color (0-1, higher = lighter). */ +function getLuminance(r: number, g: number, b: number): number { + const toLinear = (c: number) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); +} + +/** Adjust color brightness. Factor > 1 lightens, < 1 darkens. */ +function adjustBrightness(color: string, factor: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const adjust = (c: number) => Math.min(255, Math.max(0, Math.round(c * factor))); + return `rgb(${adjust(parsed.r)}, ${adjust(parsed.g)}, ${adjust(parsed.b)})`; +} + +/** Derive export background colors from a base color (e.g., userMessageBg). */ +function deriveExportColors(baseColor: string): { pageBg: string; cardBg: string; infoBg: string } { + const parsed = parseColor(baseColor); + if (!parsed) { + return { + pageBg: "rgb(24, 24, 30)", + cardBg: "rgb(30, 30, 36)", + infoBg: "rgb(60, 55, 40)", + }; + } + + const luminance = getLuminance(parsed.r, parsed.g, parsed.b); + const isLight = luminance > 0.5; + + if (isLight) { + return { + pageBg: adjustBrightness(baseColor, 0.96), + cardBg: baseColor, + infoBg: `rgb(${Math.min(255, parsed.r + 10)}, ${Math.min(255, parsed.g + 5)}, ${Math.max(0, parsed.b - 20)})`, + }; + } + return { + pageBg: adjustBrightness(baseColor, 0.7), + cardBg: adjustBrightness(baseColor, 0.85), + infoBg: `rgb(${Math.min(255, parsed.r + 20)}, ${Math.min(255, parsed.g + 15)}, ${parsed.b})`, + }; +} + +/** + * Generate CSS custom property declarations from theme colors. + */ +function generateThemeVars(themeName?: string): string { + const colors = getResolvedThemeColors(themeName); + const lines: string[] = []; + for (const [key, value] of Object.entries(colors)) { + lines.push(`--${key}: ${value};`); + } + + // Use explicit theme export colors if available, otherwise derive from userMessageBg + const themeExport = getThemeExportColors(themeName); + const userMessageBg = colors.userMessageBg || "#343541"; + const derivedColors = deriveExportColors(userMessageBg); + + lines.push(`--exportPageBg: ${themeExport.pageBg ?? derivedColors.pageBg};`); + lines.push(`--exportCardBg: ${themeExport.cardBg ?? derivedColors.cardBg};`); + lines.push(`--exportInfoBg: ${themeExport.infoBg ?? derivedColors.infoBg};`); + + return lines.join("\n "); +} + +interface SessionData { + header: ReturnType; + entries: ReturnType; + leafId: string | null; + systemPrompt?: string; + tools?: { name: string; description: string }[]; +} + +/** + * Core HTML generation logic shared by both export functions. + */ +function generateHtml(sessionData: SessionData, themeName?: string): string { + const templateDir = getExportTemplateDir(); + const template = readFileSync(join(templateDir, "template.html"), "utf-8"); + const templateCss = readFileSync(join(templateDir, "template.css"), "utf-8"); + const templateJs = readFileSync(join(templateDir, "template.js"), "utf-8"); + const markedJs = readFileSync(join(templateDir, "vendor", "marked.min.js"), "utf-8"); + const hljsJs = readFileSync(join(templateDir, "vendor", "highlight.min.js"), "utf-8"); + + const themeVars = generateThemeVars(themeName); + const colors = getResolvedThemeColors(themeName); + const exportColors = deriveExportColors(colors.userMessageBg || "#343541"); + const bodyBg = exportColors.pageBg; + const containerBg = exportColors.cardBg; + const infoBg = exportColors.infoBg; + + // Base64 encode session data to avoid escaping issues + const sessionDataBase64 = Buffer.from(JSON.stringify(sessionData)).toString("base64"); + + // Build the CSS with theme variables injected + const css = templateCss + .replace("{{THEME_VARS}}", themeVars) + .replace("{{BODY_BG}}", bodyBg) + .replace("{{CONTAINER_BG}}", containerBg) + .replace("{{INFO_BG}}", infoBg); + + return template + .replace("{{CSS}}", css) + .replace("{{JS}}", templateJs) + .replace("{{SESSION_DATA}}", sessionDataBase64) + .replace("{{MARKED_JS}}", markedJs) + .replace("{{HIGHLIGHT_JS}}", hljsJs); +} + +/** + * Export session to HTML using SessionManager and AgentState. + * Used by TUI's /export command. + */ +export function exportSessionToHtml(sm: SessionManager, state?: AgentState, options?: ExportOptions | string): string { + const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + + const sessionFile = sm.getSessionFile(); + if (!sessionFile) { + throw new Error("Cannot export in-memory session to HTML"); + } + if (!existsSync(sessionFile)) { + throw new Error("Nothing to export yet - start a conversation first"); + } + + const sessionData: SessionData = { + header: sm.getHeader(), + entries: sm.getEntries(), + leafId: sm.getLeafId(), + systemPrompt: state?.systemPrompt, + tools: state?.tools?.map((t) => ({ name: t.name, description: t.description })), + }; + + const html = generateHtml(sessionData, opts.themeName); + + let outputPath = opts.outputPath; + if (!outputPath) { + const sessionBasename = basename(sessionFile, ".jsonl"); + outputPath = `${APP_NAME}-session-${sessionBasename}.html`; + } + + writeFileSync(outputPath, html, "utf8"); + return outputPath; +} + +/** + * Export session file to HTML (standalone, without AgentState). + * Used by CLI for exporting arbitrary session files. + */ +export function exportFromFile(inputPath: string, options?: ExportOptions | string): string { + const opts: ExportOptions = typeof options === "string" ? { outputPath: options } : options || {}; + + if (!existsSync(inputPath)) { + throw new Error(`File not found: ${inputPath}`); + } + + const sm = SessionManager.open(inputPath); + + const sessionData: SessionData = { + header: sm.getHeader(), + entries: sm.getEntries(), + leafId: sm.getLeafId(), + systemPrompt: undefined, + tools: undefined, + }; + + const html = generateHtml(sessionData, opts.themeName); + + let outputPath = opts.outputPath; + if (!outputPath) { + const inputBasename = basename(inputPath, ".jsonl"); + outputPath = `${APP_NAME}-session-${inputBasename}.html`; + } + + writeFileSync(outputPath, html, "utf8"); + return outputPath; +} diff --git a/packages/coding-agent/src/core/export-html/template.css b/packages/coding-agent/src/core/export-html/template.css new file mode 100644 index 00000000..0763a3fd --- /dev/null +++ b/packages/coding-agent/src/core/export-html/template.css @@ -0,0 +1,781 @@ + :root { + {{THEME_VARS}} + --body-bg: {{BODY_BG}}; + --container-bg: {{CONTAINER_BG}}; + --info-bg: {{INFO_BG}}; + } + + * { margin: 0; padding: 0; box-sizing: border-box; } + + :root { + --line-height: 18px; /* 12px font * 1.5 */ + } + + body { + font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; + font-size: 12px; + line-height: var(--line-height); + color: var(--text); + background: var(--body-bg); + } + + #app { + display: flex; + min-height: 100vh; + } + + /* Sidebar */ + #sidebar { + width: 400px; + background: var(--container-bg); + flex-shrink: 0; + display: flex; + flex-direction: column; + position: sticky; + top: 0; + height: 100vh; + border-right: 1px solid var(--dim); + } + + .sidebar-header { + padding: 8px 12px; + flex-shrink: 0; + } + + .sidebar-controls { + padding: 8px 8px 4px 8px; + } + + .sidebar-search { + width: 100%; + box-sizing: border-box; + padding: 4px 8px; + font-size: 11px; + font-family: inherit; + background: var(--body-bg); + color: var(--text); + border: 1px solid var(--dim); + border-radius: 3px; + } + + .sidebar-filters { + display: flex; + padding: 4px 8px 8px 8px; + gap: 4px; + align-items: center; + flex-wrap: wrap; + } + + .sidebar-search:focus { + outline: none; + border-color: var(--accent); + } + + .sidebar-search::placeholder { + color: var(--muted); + } + + .filter-btn { + padding: 3px 8px; + font-size: 10px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; + } + + .filter-btn:hover { + color: var(--text); + border-color: var(--text); + } + + .filter-btn.active { + background: var(--accent); + color: var(--body-bg); + border-color: var(--accent); + } + + .sidebar-close { + display: none; + padding: 3px 8px; + font-size: 12px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; + margin-left: auto; + } + + .sidebar-close:hover { + color: var(--text); + border-color: var(--text); + } + + .tree-container { + flex: 1; + overflow: auto; + padding: 4px 0; + } + + .tree-node { + padding: 0 8px; + cursor: pointer; + display: flex; + align-items: baseline; + font-size: 11px; + line-height: 13px; + white-space: nowrap; + } + + .tree-node:hover { + background: var(--selectedBg); + } + + .tree-node.active { + background: var(--selectedBg); + } + + .tree-node.active .tree-content { + font-weight: bold; + } + + .tree-node.in-path { + } + + .tree-prefix { + color: var(--muted); + flex-shrink: 0; + font-family: monospace; + white-space: pre; + } + + .tree-marker { + color: var(--accent); + flex-shrink: 0; + } + + .tree-content { + color: var(--text); + } + + .tree-role-user { + color: var(--accent); + } + + .tree-role-assistant { + color: var(--success); + } + + .tree-role-tool { + color: var(--muted); + } + + .tree-muted { + color: var(--muted); + } + + .tree-error { + color: var(--error); + } + + .tree-compaction { + color: var(--borderAccent); + } + + .tree-branch-summary { + color: var(--warning); + } + + .tree-custom-message { + color: var(--customMessageLabel); + } + + .tree-status { + padding: 4px 12px; + font-size: 10px; + color: var(--muted); + flex-shrink: 0; + } + + /* Main content */ + #content { + flex: 1; + overflow-y: auto; + padding: var(--line-height) calc(var(--line-height) * 2); + display: flex; + flex-direction: column; + align-items: center; + } + + #content > * { + width: 100%; + max-width: 800px; + } + + /* Help bar */ + .help-bar { + font-size: 11px; + color: var(--warning); + margin-bottom: var(--line-height); + } + + /* Header */ + .header { + background: var(--container-bg); + border-radius: 4px; + padding: var(--line-height); + margin-bottom: var(--line-height); + } + + .header h1 { + font-size: 12px; + font-weight: bold; + color: var(--borderAccent); + margin-bottom: var(--line-height); + } + + .header-info { + display: flex; + flex-direction: column; + gap: 0; + font-size: 11px; + } + + .info-item { + color: var(--dim); + display: flex; + align-items: baseline; + } + + .info-label { + font-weight: 600; + margin-right: 8px; + min-width: 100px; + } + + .info-value { + color: var(--text); + flex: 1; + } + + /* Messages */ + #messages { + display: flex; + flex-direction: column; + gap: var(--line-height); + } + + .message-timestamp { + font-size: 10px; + color: var(--dim); + opacity: 0.8; + } + + .user-message { + background: var(--userMessageBg); + color: var(--userMessageText); + padding: var(--line-height); + border-radius: 4px; + } + + .assistant-message { + padding: 0; + } + + .assistant-message > .message-timestamp { + padding-left: var(--line-height); + } + + .assistant-text { + padding: var(--line-height); + padding-bottom: 0; + } + + .message-timestamp + .assistant-text, + .message-timestamp + .thinking-block { + padding-top: 0; + } + + .thinking-block + .assistant-text { + padding-top: 0; + } + + .thinking-text { + padding: var(--line-height); + color: var(--thinkingText); + font-style: italic; + white-space: pre-wrap; + } + + .message-timestamp + .thinking-block .thinking-text, + .message-timestamp + .thinking-block .thinking-collapsed { + padding-top: 0; + } + + .thinking-collapsed { + display: none; + padding: var(--line-height); + color: var(--thinkingText); + font-style: italic; + } + + /* Tool execution */ + .tool-execution { + padding: var(--line-height); + border-radius: 4px; + } + + .tool-execution + .tool-execution { + margin-top: var(--line-height); + } + + .tool-execution.pending { background: var(--toolPendingBg); } + .tool-execution.success { background: var(--toolSuccessBg); } + .tool-execution.error { background: var(--toolErrorBg); } + + .tool-header, .tool-name { + font-weight: bold; + } + + .tool-path { + color: var(--accent); + word-break: break-all; + } + + .line-numbers { + color: var(--warning); + } + + .line-count { + color: var(--dim); + } + + .tool-command { + font-weight: bold; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + } + + .tool-output { + margin-top: var(--line-height); + color: var(--toolOutput); + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + font-family: inherit; + overflow-x: auto; + } + + .tool-output > div, + .output-preview, + .output-full { + margin: 0; + padding: 0; + line-height: var(--line-height); + } + + .tool-output pre { + margin: 0; + padding: 0; + font-family: inherit; + color: inherit; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: break-word; + } + + .tool-output code { + padding: 0; + background: none; + color: var(--text); + } + + .tool-output.expandable { + cursor: pointer; + } + + .tool-output.expandable:hover { + opacity: 0.9; + } + + .tool-output.expandable .output-full { + display: none; + } + + .tool-output.expandable.expanded .output-preview { + display: none; + } + + .tool-output.expandable.expanded .output-full { + display: block; + } + + .tool-images { + } + + .tool-image { + max-width: 100%; + max-height: 500px; + border-radius: 4px; + margin: var(--line-height) 0; + } + + .expand-hint { + color: var(--toolOutput); + } + + /* Diff */ + .tool-diff { + font-size: 11px; + overflow-x: auto; + white-space: pre; + } + + .diff-added { color: var(--toolDiffAdded); } + .diff-removed { color: var(--toolDiffRemoved); } + .diff-context { color: var(--toolDiffContext); } + + /* Model change */ + .model-change { + padding: 0 var(--line-height); + color: var(--dim); + font-size: 11px; + } + + .model-name { + color: var(--borderAccent); + font-weight: bold; + } + + /* Compaction / Branch Summary - matches customMessage colors from TUI */ + .compaction { + background: var(--customMessageBg); + border-radius: 4px; + padding: var(--line-height); + cursor: pointer; + } + + .compaction-label { + color: var(--customMessageLabel); + font-weight: bold; + } + + .compaction-collapsed { + color: var(--customMessageText); + } + + .compaction-content { + display: none; + color: var(--customMessageText); + white-space: pre-wrap; + margin-top: var(--line-height); + } + + .compaction.expanded .compaction-collapsed { + display: none; + } + + .compaction.expanded .compaction-content { + display: block; + } + + /* System prompt */ + .system-prompt { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; + margin-bottom: var(--line-height); + } + + .system-prompt-header { + font-weight: bold; + color: var(--customMessageLabel); + } + + .system-prompt-content { + color: var(--customMessageText); + white-space: pre-wrap; + word-wrap: break-word; + font-size: 11px; + max-height: 200px; + overflow-y: auto; + margin-top: var(--line-height); + } + + /* Tools list */ + .tools-list { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; + margin-bottom: var(--line-height); + } + + .tools-header { + font-weight: bold; + color: var(--warning); + margin-bottom: var(--line-height); + } + + .tool-item { + font-size: 11px; + } + + .tool-item-name { + font-weight: bold; + color: var(--text); + } + + .tool-item-desc { + color: var(--dim); + } + + /* Hook/custom messages */ + .hook-message { + background: var(--customMessageBg); + color: var(--customMessageText); + padding: var(--line-height); + border-radius: 4px; + } + + .hook-type { + color: var(--customMessageLabel); + font-weight: bold; + } + + /* Branch summary */ + .branch-summary { + background: var(--customMessageBg); + padding: var(--line-height); + border-radius: 4px; + } + + .branch-summary-header { + font-weight: bold; + color: var(--borderAccent); + } + + /* Error */ + .error-text { + color: var(--error); + padding: 0 var(--line-height); + } + + /* Images */ + .message-images { + margin-bottom: 12px; + } + + .message-image { + max-width: 100%; + max-height: 400px; + border-radius: 4px; + margin: var(--line-height) 0; + } + + /* Markdown content */ + .markdown-content h1, + .markdown-content h2, + .markdown-content h3, + .markdown-content h4, + .markdown-content h5, + .markdown-content h6 { + color: var(--mdHeading); + margin: var(--line-height) 0 0 0; + font-weight: bold; + } + + .markdown-content h1 { font-size: 1em; } + .markdown-content h2 { font-size: 1em; } + .markdown-content h3 { font-size: 1em; } + .markdown-content h4 { font-size: 1em; } + .markdown-content h5 { font-size: 1em; } + .markdown-content h6 { font-size: 1em; } + .markdown-content p { margin: 0; } + .markdown-content p + p { margin-top: var(--line-height); } + + .markdown-content a { + color: var(--mdLink); + text-decoration: underline; + } + + .markdown-content code { + background: rgba(128, 128, 128, 0.2); + color: var(--mdCode); + padding: 0 4px; + border-radius: 3px; + font-family: inherit; + } + + .markdown-content pre { + background: transparent; + margin: var(--line-height) 0; + overflow-x: auto; + } + + .markdown-content pre code { + display: block; + background: none; + color: var(--text); + } + + .markdown-content blockquote { + border-left: 3px solid var(--mdQuoteBorder); + padding-left: var(--line-height); + margin: var(--line-height) 0; + color: var(--mdQuote); + font-style: italic; + } + + .markdown-content ul, + .markdown-content ol { + margin: var(--line-height) 0; + padding-left: calc(var(--line-height) * 2); + } + + .markdown-content li { margin: 0; } + .markdown-content li::marker { color: var(--mdListBullet); } + + .markdown-content hr { + border: none; + border-top: 1px solid var(--mdHr); + margin: var(--line-height) 0; + } + + .markdown-content table { + border-collapse: collapse; + margin: 0.5em 0; + width: 100%; + } + + .markdown-content th, + .markdown-content td { + border: 1px solid var(--mdCodeBlockBorder); + padding: 6px 10px; + text-align: left; + } + + .markdown-content th { + background: rgba(128, 128, 128, 0.1); + font-weight: bold; + } + + .markdown-content img { + max-width: 100%; + border-radius: 4px; + } + + /* Syntax highlighting */ + .hljs { background: transparent; color: var(--text); } + .hljs-comment, .hljs-quote { color: var(--syntaxComment); } + .hljs-keyword, .hljs-selector-tag { color: var(--syntaxKeyword); } + .hljs-number, .hljs-literal { color: var(--syntaxNumber); } + .hljs-string, .hljs-doctag { color: var(--syntaxString); } + /* Function names: hljs v11 uses .hljs-title.function_ compound class */ + .hljs-function, .hljs-title, .hljs-title.function_, .hljs-section, .hljs-name { color: var(--syntaxFunction); } + /* Types: hljs v11 uses .hljs-title.class_ for class names */ + .hljs-type, .hljs-class, .hljs-title.class_, .hljs-built_in { color: var(--syntaxType); } + .hljs-attr, .hljs-variable, .hljs-variable.language_, .hljs-params, .hljs-property { color: var(--syntaxVariable); } + .hljs-meta, .hljs-meta .hljs-keyword, .hljs-meta .hljs-string { color: var(--syntaxKeyword); } + .hljs-operator { color: var(--syntaxOperator); } + .hljs-punctuation { color: var(--syntaxPunctuation); } + .hljs-subst { color: var(--text); } + + /* Footer */ + .footer { + margin-top: 48px; + padding: 20px; + text-align: center; + color: var(--dim); + font-size: 10px; + } + + /* Mobile */ + #hamburger { + display: none; + position: fixed; + top: 10px; + left: 10px; + z-index: 100; + padding: 3px 8px; + font-size: 12px; + font-family: inherit; + background: transparent; + color: var(--muted); + border: 1px solid var(--dim); + border-radius: 3px; + cursor: pointer; + } + + #hamburger:hover { + color: var(--text); + border-color: var(--text); + } + + + + #sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 98; + } + + @media (max-width: 900px) { + #sidebar { + position: fixed; + left: -400px; + width: 400px; + top: 0; + bottom: 0; + height: 100vh; + z-index: 99; + transition: left 0.3s; + } + + #sidebar.open { + left: 0; + } + + #sidebar-overlay.open { + display: block; + } + + #hamburger { + display: block; + } + + .sidebar-close { + display: block; + } + + #content { + padding: var(--line-height) 16px; + } + + #content > * { + max-width: 100%; + } + } + + @media (max-width: 500px) { + #sidebar { + width: 100vw; + left: -100vw; + } + } + + @media print { + #sidebar, #sidebar-toggle { display: none !important; } + body { background: white; color: black; } + #content { max-width: none; } + } diff --git a/packages/coding-agent/src/core/export-html/template.html b/packages/coding-agent/src/core/export-html/template.html new file mode 100644 index 00000000..42f2a45b --- /dev/null +++ b/packages/coding-agent/src/core/export-html/template.html @@ -0,0 +1,54 @@ + + + + + + Session Export + + + + + +
+ +
+
+
+
+
+ +
+
+ + + + + + + + + + + + + diff --git a/packages/coding-agent/src/core/export-html/template.js b/packages/coding-agent/src/core/export-html/template.js new file mode 100644 index 00000000..b6137a9b --- /dev/null +++ b/packages/coding-agent/src/core/export-html/template.js @@ -0,0 +1,1185 @@ + (function() { + 'use strict'; + + // ============================================================ + // DATA LOADING + // ============================================================ + + const base64 = document.getElementById('session-data').textContent; + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + const data = JSON.parse(new TextDecoder('utf-8').decode(bytes)); + const { header, entries, leafId, systemPrompt, tools } = data; + + // ============================================================ + // DATA STRUCTURES + // ============================================================ + + // Entry lookup by ID + const byId = new Map(); + for (const entry of entries) { + byId.set(entry.id, entry); + } + + // Tool call lookup (toolCallId -> {name, arguments}) + const toolCallMap = new Map(); + for (const entry of entries) { + if (entry.type === 'message' && entry.message.role === 'assistant') { + const content = entry.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'toolCall') { + toolCallMap.set(block.id, { name: block.name, arguments: block.arguments }); + } + } + } + } + } + + // Label lookup (entryId -> label string) + // Labels are stored in 'label' entries that reference their target via parentId + const labelMap = new Map(); + for (const entry of entries) { + if (entry.type === 'label' && entry.parentId && entry.label) { + labelMap.set(entry.parentId, entry.label); + } + } + + // ============================================================ + // TREE DATA PREPARATION (no DOM, pure data) + // ============================================================ + + /** + * Build tree structure from flat entries. + * Returns array of root nodes, each with { entry, children, label }. + */ + function buildTree() { + const nodeMap = new Map(); + const roots = []; + + // Create nodes + for (const entry of entries) { + nodeMap.set(entry.id, { + entry, + children: [], + label: labelMap.get(entry.id) + }); + } + + // Build parent-child relationships + for (const entry of entries) { + const node = nodeMap.get(entry.id); + if (entry.parentId === null || entry.parentId === undefined || entry.parentId === entry.id) { + roots.push(node); + } else { + const parent = nodeMap.get(entry.parentId); + if (parent) { + parent.children.push(node); + } else { + roots.push(node); + } + } + } + + // Sort children by timestamp + function sortChildren(node) { + node.children.sort((a, b) => + new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime() + ); + node.children.forEach(sortChildren); + } + roots.forEach(sortChildren); + + return roots; + } + + /** + * Build set of entry IDs on path from root to target. + */ + function buildActivePathIds(targetId) { + const ids = new Set(); + let current = byId.get(targetId); + while (current) { + ids.add(current.id); + // Stop if no parent or self-referencing (root) + if (!current.parentId || current.parentId === current.id) { + break; + } + current = byId.get(current.parentId); + } + return ids; + } + + /** + * Get array of entries from root to target (the conversation path). + */ + function getPath(targetId) { + const path = []; + let current = byId.get(targetId); + while (current) { + path.unshift(current); + // Stop if no parent or self-referencing (root) + if (!current.parentId || current.parentId === current.id) { + break; + } + current = byId.get(current.parentId); + } + return path; + } + + /** + * Flatten tree into list with indentation and connector info. + * Returns array of { node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }. + * Matches tree-selector.ts logic exactly. + */ + function flattenTree(roots, activePathIds) { + const result = []; + const multipleRoots = roots.length > 1; + + // Mark which subtrees contain the active leaf + const containsActive = new Map(); + function markActive(node) { + let has = activePathIds.has(node.entry.id); + for (const child of node.children) { + if (markActive(child)) has = true; + } + containsActive.set(node, has); + return has; + } + roots.forEach(markActive); + + // Stack: [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] + const stack = []; + + // Add roots (prioritize branch containing active leaf) + const orderedRoots = [...roots].sort((a, b) => + Number(containsActive.get(b)) - Number(containsActive.get(a)) + ); + for (let i = orderedRoots.length - 1; i >= 0; i--) { + const isLast = i === orderedRoots.length - 1; + stack.push([orderedRoots[i], multipleRoots ? 1 : 0, multipleRoots, multipleRoots, isLast, [], multipleRoots]); + } + + while (stack.length > 0) { + const [node, indent, justBranched, showConnector, isLast, gutters, isVirtualRootChild] = stack.pop(); + + result.push({ node, indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots }); + + const children = node.children; + const multipleChildren = children.length > 1; + + // Order children (active branch first) + const orderedChildren = [...children].sort((a, b) => + Number(containsActive.get(b)) - Number(containsActive.get(a)) + ); + + // Calculate child indent (matches tree-selector.ts) + let childIndent; + if (multipleChildren) { + // Parent branches: children get +1 + childIndent = indent + 1; + } else if (justBranched && indent > 0) { + // First generation after a branch: +1 for visual grouping + childIndent = indent + 1; + } else { + // Single-child chain: stay flat + childIndent = indent; + } + + // Build gutters for children + const connectorDisplayed = showConnector && !isVirtualRootChild; + const currentDisplayIndent = multipleRoots ? Math.max(0, indent - 1) : indent; + const connectorPosition = Math.max(0, currentDisplayIndent - 1); + const childGutters = connectorDisplayed + ? [...gutters, { position: connectorPosition, show: !isLast }] + : gutters; + + // Add children in reverse order for stack + for (let i = orderedChildren.length - 1; i >= 0; i--) { + const childIsLast = i === orderedChildren.length - 1; + stack.push([orderedChildren[i], childIndent, multipleChildren, multipleChildren, childIsLast, childGutters, false]); + } + } + + return result; + } + + /** + * Build ASCII prefix string for tree node. + */ + function buildTreePrefix(flatNode) { + const { indent, showConnector, isLast, gutters, isVirtualRootChild, multipleRoots } = flatNode; + const displayIndent = multipleRoots ? Math.max(0, indent - 1) : indent; + const connector = showConnector && !isVirtualRootChild ? (isLast ? 'โ””โ”€ ' : 'โ”œโ”€ ') : ''; + const connectorPosition = connector ? displayIndent - 1 : -1; + + const totalChars = displayIndent * 3; + const prefixChars = []; + for (let i = 0; i < totalChars; i++) { + const level = Math.floor(i / 3); + const posInLevel = i % 3; + + const gutter = gutters.find(g => g.position === level); + if (gutter) { + prefixChars.push(posInLevel === 0 ? (gutter.show ? 'โ”‚' : ' ') : ' '); + } else if (connector && level === connectorPosition) { + if (posInLevel === 0) { + prefixChars.push(isLast ? 'โ””' : 'โ”œ'); + } else if (posInLevel === 1) { + prefixChars.push('โ”€'); + } else { + prefixChars.push(' '); + } + } else { + prefixChars.push(' '); + } + } + return prefixChars.join(''); + } + + // ============================================================ + // FILTERING (pure data) + // ============================================================ + + let filterMode = 'default'; + let searchQuery = ''; + + function hasTextContent(content) { + if (typeof content === 'string') return content.trim().length > 0; + if (Array.isArray(content)) { + for (const c of content) { + if (c.type === 'text' && c.text && c.text.trim().length > 0) return true; + } + } + return false; + } + + function extractContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(c => c.type === 'text' && c.text) + .map(c => c.text) + .join(''); + } + return ''; + } + + function getSearchableText(entry, label) { + const parts = []; + if (label) parts.push(label); + + switch (entry.type) { + case 'message': { + const msg = entry.message; + parts.push(msg.role); + if (msg.content) parts.push(extractContent(msg.content)); + if (msg.role === 'bashExecution' && msg.command) parts.push(msg.command); + break; + } + case 'custom_message': + parts.push(entry.customType); + parts.push(typeof entry.content === 'string' ? entry.content : extractContent(entry.content)); + break; + case 'compaction': + parts.push('compaction'); + break; + case 'branch_summary': + parts.push('branch summary', entry.summary); + break; + case 'model_change': + parts.push('model', entry.modelId); + break; + case 'thinking_level_change': + parts.push('thinking', entry.thinkingLevel); + break; + } + + return parts.join(' ').toLowerCase(); + } + + /** + * Filter flat nodes based on current filterMode and searchQuery. + */ + function filterNodes(flatNodes, currentLeafId) { + const searchTokens = searchQuery.toLowerCase().split(/\s+/).filter(Boolean); + + return flatNodes.filter(flatNode => { + const entry = flatNode.node.entry; + const label = flatNode.node.label; + const isCurrentLeaf = entry.id === currentLeafId; + + // Always show current leaf + if (isCurrentLeaf) return true; + + // Hide assistant messages with only tool calls (no text) unless error/aborted + if (entry.type === 'message' && entry.message.role === 'assistant') { + const msg = entry.message; + const hasText = hasTextContent(msg.content); + const isErrorOrAborted = msg.stopReason && msg.stopReason !== 'stop' && msg.stopReason !== 'toolUse'; + if (!hasText && !isErrorOrAborted) return false; + } + + // Apply filter mode + const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change'].includes(entry.type); + let passesFilter = true; + + switch (filterMode) { + case 'user-only': + passesFilter = entry.type === 'message' && entry.message.role === 'user'; + break; + case 'no-tools': + passesFilter = !isSettingsEntry && !(entry.type === 'message' && entry.message.role === 'toolResult'); + break; + case 'labeled-only': + passesFilter = label !== undefined; + break; + case 'all': + passesFilter = true; + break; + default: // 'default' + passesFilter = !isSettingsEntry; + break; + } + + if (!passesFilter) return false; + + // Apply search filter + if (searchTokens.length > 0) { + const nodeText = getSearchableText(entry, label); + if (!searchTokens.every(t => nodeText.includes(t))) return false; + } + + return true; + }); + } + + // ============================================================ + // TREE DISPLAY TEXT (pure data -> string) + // ============================================================ + + function shortenPath(p) { + if (p.startsWith('/Users/')) { + const parts = p.split('/'); + if (parts.length > 2) return '~' + p.slice(('/Users/' + parts[2]).length); + } + if (p.startsWith('/home/')) { + const parts = p.split('/'); + if (parts.length > 2) return '~' + p.slice(('/home/' + parts[2]).length); + } + return p; + } + + function formatToolCall(name, args) { + switch (name) { + case 'read': { + const path = shortenPath(String(args.path || args.file_path || '')); + const offset = args.offset; + const limit = args.limit; + let display = path; + if (offset !== undefined || limit !== undefined) { + const start = offset ?? 1; + const end = limit !== undefined ? start + limit - 1 : ''; + display += `:${start}${end ? `-${end}` : ''}`; + } + return `[read: ${display}]`; + } + case 'write': + return `[write: ${shortenPath(String(args.path || args.file_path || ''))}]`; + case 'edit': + return `[edit: ${shortenPath(String(args.path || args.file_path || ''))}]`; + case 'bash': { + const rawCmd = String(args.command || ''); + const cmd = rawCmd.replace(/[\n\t]/g, ' ').trim().slice(0, 50); + return `[bash: ${cmd}${rawCmd.length > 50 ? '...' : ''}]`; + } + case 'grep': + return `[grep: /${args.pattern || ''}/ in ${shortenPath(String(args.path || '.'))}]`; + case 'find': + return `[find: ${args.pattern || ''} in ${shortenPath(String(args.path || '.'))}]`; + case 'ls': + return `[ls: ${shortenPath(String(args.path || '.'))}]`; + default: { + const argsStr = JSON.stringify(args).slice(0, 40); + return `[${name}: ${argsStr}${JSON.stringify(args).length > 40 ? '...' : ''}]`; + } + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + /** + * Truncate string to maxLen chars, append "..." if truncated. + */ + function truncate(s, maxLen = 100) { + if (s.length <= maxLen) return s; + return s.slice(0, maxLen) + '...'; + } + + /** + * Get display text for tree node (returns HTML string). + */ + function getTreeNodeDisplayHtml(entry, label) { + const normalize = s => s.replace(/[\n\t]/g, ' ').trim(); + const labelHtml = label ? `[${escapeHtml(label)}] ` : ''; + + switch (entry.type) { + case 'message': { + const msg = entry.message; + if (msg.role === 'user') { + const content = truncate(normalize(extractContent(msg.content))); + return labelHtml + `user: ${escapeHtml(content)}`; + } + if (msg.role === 'assistant') { + const textContent = truncate(normalize(extractContent(msg.content))); + if (textContent) { + return labelHtml + `assistant: ${escapeHtml(textContent)}`; + } + if (msg.stopReason === 'aborted') { + return labelHtml + `assistant: (aborted)`; + } + if (msg.errorMessage) { + return labelHtml + `assistant: ${escapeHtml(truncate(msg.errorMessage))}`; + } + return labelHtml + `assistant: (no text)`; + } + if (msg.role === 'toolResult') { + const toolCall = msg.toolCallId ? toolCallMap.get(msg.toolCallId) : null; + if (toolCall) { + return labelHtml + `${escapeHtml(formatToolCall(toolCall.name, toolCall.arguments))}`; + } + return labelHtml + `[${msg.toolName || 'tool'}]`; + } + if (msg.role === 'bashExecution') { + const cmd = truncate(normalize(msg.command || '')); + return labelHtml + `[bash]: ${escapeHtml(cmd)}`; + } + return labelHtml + `[${msg.role}]`; + } + case 'compaction': + return labelHtml + `[compaction: ${Math.round(entry.tokensBefore/1000)}k tokens]`; + case 'branch_summary': { + const summary = truncate(normalize(entry.summary || '')); + return labelHtml + `[branch summary]: ${escapeHtml(summary)}`; + } + case 'custom_message': { + const content = typeof entry.content === 'string' ? entry.content : extractContent(entry.content); + return labelHtml + `[${escapeHtml(entry.customType)}]: ${escapeHtml(truncate(normalize(content)))}`; + } + case 'model_change': + return labelHtml + `[model: ${entry.modelId}]`; + case 'thinking_level_change': + return labelHtml + `[thinking: ${entry.thinkingLevel}]`; + default: + return labelHtml + `[${entry.type}]`; + } + } + + // ============================================================ + // TREE RENDERING (DOM manipulation) + // ============================================================ + + let currentLeafId = leafId; + let treeRendered = false; + + function renderTree() { + const tree = buildTree(); + const activePathIds = buildActivePathIds(currentLeafId); + const flatNodes = flattenTree(tree, activePathIds); + const filtered = filterNodes(flatNodes, currentLeafId); + const container = document.getElementById('tree-container'); + + // Full render only on first call or when filter/search changes + if (!treeRendered) { + container.innerHTML = ''; + + for (const flatNode of filtered) { + const entry = flatNode.node.entry; + const isOnPath = activePathIds.has(entry.id); + const isLeaf = entry.id === currentLeafId; + + const div = document.createElement('div'); + div.className = 'tree-node'; + if (isOnPath) div.classList.add('in-path'); + if (isLeaf) div.classList.add('active'); + div.dataset.id = entry.id; + + const prefix = buildTreePrefix(flatNode); + const prefixSpan = document.createElement('span'); + prefixSpan.className = 'tree-prefix'; + prefixSpan.textContent = prefix; + + const marker = document.createElement('span'); + marker.className = 'tree-marker'; + marker.textContent = isOnPath ? 'โ€ข' : ' '; + + const content = document.createElement('span'); + content.className = 'tree-content'; + content.innerHTML = getTreeNodeDisplayHtml(entry, flatNode.node.label); + + div.appendChild(prefixSpan); + div.appendChild(marker); + div.appendChild(content); + div.addEventListener('click', () => navigateTo(entry.id)); + + container.appendChild(div); + } + + treeRendered = true; + } else { + // Just update markers and classes + const nodes = container.querySelectorAll('.tree-node'); + for (const node of nodes) { + const id = node.dataset.id; + const isOnPath = activePathIds.has(id); + const isLeaf = id === currentLeafId; + + node.classList.toggle('in-path', isOnPath); + node.classList.toggle('active', isLeaf); + + const marker = node.querySelector('.tree-marker'); + if (marker) { + marker.textContent = isOnPath ? 'โ€ข' : ' '; + } + } + } + + document.getElementById('tree-status').textContent = `${filtered.length} / ${flatNodes.length} entries`; + + // Scroll active node into view after layout + setTimeout(() => { + const activeNode = container.querySelector('.tree-node.active'); + if (activeNode) { + activeNode.scrollIntoView({ block: 'nearest' }); + } + }, 0); + } + + function forceTreeRerender() { + treeRendered = false; + renderTree(); + } + + // ============================================================ + // MESSAGE RENDERING + // ============================================================ + + function formatTokens(count) { + if (count < 1000) return count.toString(); + if (count < 10000) return (count / 1000).toFixed(1) + 'k'; + if (count < 1000000) return Math.round(count / 1000) + 'k'; + return (count / 1000000).toFixed(1) + 'M'; + } + + function formatTimestamp(ts) { + if (!ts) return ''; + const date = new Date(ts); + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } + + function replaceTabs(text) { + return text.replace(/\t/g, ' '); + } + + function getLanguageFromPath(filePath) { + const ext = filePath.split('.').pop()?.toLowerCase(); + const extToLang = { + ts: 'typescript', tsx: 'typescript', js: 'javascript', jsx: 'javascript', + py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java', + c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp', + php: 'php', sh: 'bash', bash: 'bash', zsh: 'bash', + sql: 'sql', html: 'html', css: 'css', scss: 'scss', + json: 'json', yaml: 'yaml', yml: 'yaml', xml: 'xml', + md: 'markdown', dockerfile: 'dockerfile' + }; + return extToLang[ext]; + } + + function findToolResult(toolCallId) { + for (const entry of entries) { + if (entry.type === 'message' && entry.message.role === 'toolResult') { + if (entry.message.toolCallId === toolCallId) { + return entry.message; + } + } + } + return null; + } + + function formatExpandableOutput(text, maxLines, lang) { + text = replaceTabs(text); + const lines = text.split('\n'); + const displayLines = lines.slice(0, maxLines); + const remaining = lines.length - maxLines; + + if (lang) { + let highlighted; + try { + highlighted = hljs.highlight(text, { language: lang }).value; + } catch { + highlighted = escapeHtml(text); + } + + if (remaining > 0) { + const previewCode = displayLines.join('\n'); + let previewHighlighted; + try { + previewHighlighted = hljs.highlight(previewCode, { language: lang }).value; + } catch { + previewHighlighted = escapeHtml(previewCode); + } + + return ``; + } + + return `
${highlighted}
`; + } + + // Plain text output + if (remaining > 0) { + let out = ''; + return out; + } + + let out = '
'; + for (const line of displayLines) { + out += `
${escapeHtml(replaceTabs(line))}
`; + } + out += '
'; + return out; + } + + function renderToolCall(call) { + const result = findToolResult(call.id); + const isError = result?.isError || false; + const statusClass = result ? (isError ? 'error' : 'success') : 'pending'; + + const getResultText = () => { + if (!result) return ''; + const textBlocks = result.content.filter(c => c.type === 'text'); + return textBlocks.map(c => c.text).join('\n'); + }; + + const getResultImages = () => { + if (!result) return []; + return result.content.filter(c => c.type === 'image'); + }; + + const renderResultImages = () => { + const images = getResultImages(); + if (images.length === 0) return ''; + return '
' + + images.map(img => ``).join('') + + '
'; + }; + + let html = `
`; + const args = call.arguments || {}; + const name = call.name; + + switch (name) { + case 'bash': { + const command = args.command || ''; + html += `
$ ${escapeHtml(command)}
`; + if (result) { + const output = getResultText().trim(); + if (output) html += formatExpandableOutput(output, 5); + } + break; + } + case 'read': { + const filePath = args.file_path || args.path || ''; + const offset = args.offset; + const limit = args.limit; + const lang = getLanguageFromPath(filePath); + + let pathHtml = escapeHtml(shortenPath(filePath)); + if (offset !== undefined || limit !== undefined) { + const startLine = offset ?? 1; + const endLine = limit !== undefined ? startLine + limit - 1 : ''; + pathHtml += `:${startLine}${endLine ? '-' + endLine : ''}`; + } + + html += `
read ${pathHtml}
`; + if (result) { + html += renderResultImages(); + const output = getResultText(); + if (output) html += formatExpandableOutput(output, 10, lang); + } + break; + } + case 'write': { + const filePath = args.file_path || args.path || ''; + const content = args.content || ''; + const lines = content.split('\n'); + const lang = getLanguageFromPath(filePath); + + html += `
write ${escapeHtml(shortenPath(filePath))}`; + if (lines.length > 10) html += ` (${lines.length} lines)`; + html += '
'; + + if (content) html += formatExpandableOutput(content, 10, lang); + if (result) { + const output = getResultText().trim(); + if (output) html += `
${escapeHtml(output)}
`; + } + break; + } + case 'edit': { + const filePath = args.file_path || args.path || ''; + html += `
edit ${escapeHtml(shortenPath(filePath))}
`; + + if (result?.details?.diff) { + const diffLines = result.details.diff.split('\n'); + html += '
'; + for (const line of diffLines) { + const cls = line.match(/^\+/) ? 'diff-added' : line.match(/^-/) ? 'diff-removed' : 'diff-context'; + html += `
${escapeHtml(replaceTabs(line))}
`; + } + html += '
'; + } else if (result) { + const output = getResultText().trim(); + if (output) html += `
${escapeHtml(output)}
`; + } + break; + } + default: { + html += `
${escapeHtml(name)}
`; + html += `
${escapeHtml(JSON.stringify(args, null, 2))}
`; + if (result) { + const output = getResultText(); + if (output) html += formatExpandableOutput(output, 10); + } + } + } + + html += '
'; + return html; + } + + function renderEntry(entry) { + const ts = formatTimestamp(entry.timestamp); + const tsHtml = ts ? `
${ts}
` : ''; + const entryId = `entry-${entry.id}`; + + if (entry.type === 'message') { + const msg = entry.message; + + if (msg.role === 'user') { + let html = `
${tsHtml}`; + const content = msg.content; + + if (Array.isArray(content)) { + const images = content.filter(c => c.type === 'image'); + if (images.length > 0) { + html += '
'; + for (const img of images) { + html += ``; + } + html += '
'; + } + } + + const text = typeof content === 'string' ? content : + content.filter(c => c.type === 'text').map(c => c.text).join('\n'); + if (text.trim()) { + html += `
${safeMarkedParse(text)}
`; + } + html += '
'; + return html; + } + + if (msg.role === 'assistant') { + let html = `
${tsHtml}`; + + for (const block of msg.content) { + if (block.type === 'text' && block.text.trim()) { + html += `
${safeMarkedParse(block.text)}
`; + } else if (block.type === 'thinking' && block.thinking.trim()) { + html += `
+
${escapeHtml(block.thinking)}
+
Thinking ...
+
`; + } + } + + for (const block of msg.content) { + if (block.type === 'toolCall') { + html += renderToolCall(block); + } + } + + if (msg.stopReason === 'aborted') { + html += '
Aborted
'; + } else if (msg.stopReason === 'error') { + html += `
Error: ${escapeHtml(msg.errorMessage || 'Unknown error')}
`; + } + + html += '
'; + return html; + } + + if (msg.role === 'bashExecution') { + const isError = msg.cancelled || (msg.exitCode !== 0 && msg.exitCode !== null); + let html = `
${tsHtml}`; + html += `
$ ${escapeHtml(msg.command)}
`; + if (msg.output) html += formatExpandableOutput(msg.output, 10); + if (msg.cancelled) { + html += '
(cancelled)
'; + } else if (msg.exitCode !== 0 && msg.exitCode !== null) { + html += `
(exit ${msg.exitCode})
`; + } + html += '
'; + return html; + } + + if (msg.role === 'toolResult') return ''; + } + + if (entry.type === 'model_change') { + return `
${tsHtml}Switched to model: ${escapeHtml(entry.provider)}/${escapeHtml(entry.modelId)}
`; + } + + if (entry.type === 'compaction') { + return `
+
[compaction]
+
Compacted from ${entry.tokensBefore.toLocaleString()} tokens
+
Compacted from ${entry.tokensBefore.toLocaleString()} tokens\n\n${escapeHtml(entry.summary)}
+
`; + } + + if (entry.type === 'branch_summary') { + return `
${tsHtml} +
Branch Summary
+
${safeMarkedParse(entry.summary)}
+
`; + } + + if (entry.type === 'custom_message' && entry.display) { + return `
${tsHtml} +
[${escapeHtml(entry.customType)}]
+
${safeMarkedParse(typeof entry.content === 'string' ? entry.content : JSON.stringify(entry.content))}
+
`; + } + + return ''; + } + + // ============================================================ + // HEADER / STATS + // ============================================================ + + function computeStats(entryList) { + let userMessages = 0, assistantMessages = 0, toolResults = 0; + let customMessages = 0, compactions = 0, branchSummaries = 0, toolCalls = 0; + const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const cost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + const models = new Set(); + + for (const entry of entryList) { + if (entry.type === 'message') { + const msg = entry.message; + if (msg.role === 'user') userMessages++; + if (msg.role === 'assistant') { + assistantMessages++; + if (msg.model) models.add(msg.provider ? `${msg.provider}/${msg.model}` : msg.model); + if (msg.usage) { + tokens.input += msg.usage.input || 0; + tokens.output += msg.usage.output || 0; + tokens.cacheRead += msg.usage.cacheRead || 0; + tokens.cacheWrite += msg.usage.cacheWrite || 0; + if (msg.usage.cost) { + cost.input += msg.usage.cost.input || 0; + cost.output += msg.usage.cost.output || 0; + cost.cacheRead += msg.usage.cost.cacheRead || 0; + cost.cacheWrite += msg.usage.cost.cacheWrite || 0; + } + } + toolCalls += msg.content.filter(c => c.type === 'toolCall').length; + } + if (msg.role === 'toolResult') toolResults++; + } else if (entry.type === 'compaction') { + compactions++; + } else if (entry.type === 'branch_summary') { + branchSummaries++; + } else if (entry.type === 'custom_message') { + customMessages++; + } + } + + return { userMessages, assistantMessages, toolResults, customMessages, compactions, branchSummaries, toolCalls, tokens, cost, models: Array.from(models) }; + } + + const globalStats = computeStats(entries); + + function renderHeader() { + const totalCost = globalStats.cost.input + globalStats.cost.output + globalStats.cost.cacheRead + globalStats.cost.cacheWrite; + + const tokenParts = []; + if (globalStats.tokens.input) tokenParts.push(`โ†‘${formatTokens(globalStats.tokens.input)}`); + if (globalStats.tokens.output) tokenParts.push(`โ†“${formatTokens(globalStats.tokens.output)}`); + if (globalStats.tokens.cacheRead) tokenParts.push(`R${formatTokens(globalStats.tokens.cacheRead)}`); + if (globalStats.tokens.cacheWrite) tokenParts.push(`W${formatTokens(globalStats.tokens.cacheWrite)}`); + + const msgParts = []; + if (globalStats.userMessages) msgParts.push(`${globalStats.userMessages} user`); + if (globalStats.assistantMessages) msgParts.push(`${globalStats.assistantMessages} assistant`); + if (globalStats.toolResults) msgParts.push(`${globalStats.toolResults} tool results`); + if (globalStats.customMessages) msgParts.push(`${globalStats.customMessages} custom`); + if (globalStats.compactions) msgParts.push(`${globalStats.compactions} compactions`); + if (globalStats.branchSummaries) msgParts.push(`${globalStats.branchSummaries} branch summaries`); + + let html = ` +
+

Session: ${escapeHtml(header?.id || 'unknown')}

+
Ctrl+T toggle thinking ยท Ctrl+O toggle tools
+
+
Date:${header?.timestamp ? new Date(header.timestamp).toLocaleString() : 'unknown'}
+
Models:${globalStats.models.join(', ') || 'unknown'}
+
Messages:${msgParts.join(', ') || '0'}
+
Tool Calls:${globalStats.toolCalls}
+
Tokens:${tokenParts.join(' ') || '0'}
+
Cost:$${totalCost.toFixed(3)}
+
+
`; + + if (systemPrompt) { + html += `
+
System Prompt
+
${escapeHtml(systemPrompt)}
+
`; + } + + if (tools && tools.length > 0) { + html += `
+
Available Tools
+
+ ${tools.map(t => `
${escapeHtml(t.name)} - ${escapeHtml(t.description)}
`).join('')} +
+
`; + } + + return html; + } + + // ============================================================ + // NAVIGATION + // ============================================================ + + // Cache for rendered entry DOM nodes + const entryCache = new Map(); + + function renderEntryToNode(entry) { + // Check cache first + if (entryCache.has(entry.id)) { + return entryCache.get(entry.id).cloneNode(true); + } + + // Render to HTML string, then parse to node + const html = renderEntry(entry); + if (!html) return null; + + const template = document.createElement('template'); + template.innerHTML = html; + const node = template.content.firstElementChild; + + // Cache the node + if (node) { + entryCache.set(entry.id, node.cloneNode(true)); + } + return node; + } + + function navigateTo(targetId, scrollMode = 'target') { + currentLeafId = targetId; + const path = getPath(targetId); + + renderTree(); + + document.getElementById('header-container').innerHTML = renderHeader(); + + // Build messages using cached DOM nodes + const messagesEl = document.getElementById('messages'); + const fragment = document.createDocumentFragment(); + + for (const entry of path) { + const node = renderEntryToNode(entry); + if (node) { + fragment.appendChild(node); + } + } + + messagesEl.innerHTML = ''; + messagesEl.appendChild(fragment); + + // Use setTimeout(0) to ensure DOM is fully laid out before scrolling + setTimeout(() => { + const content = document.getElementById('content'); + if (scrollMode === 'bottom') { + content.scrollTop = content.scrollHeight; + } else if (scrollMode === 'target') { + const targetEl = document.getElementById(`entry-${targetId}`); + if (targetEl) { + targetEl.scrollIntoView({ block: 'center' }); + } + } + }, 0); + } + + // ============================================================ + // INITIALIZATION + // ============================================================ + + // Escape HTML tags in text (but not code blocks) + function escapeHtmlTags(text) { + return text.replace(/<(?=[a-zA-Z\/])/g, '<'); + } + + // Configure marked with syntax highlighting and HTML escaping for text + marked.use({ + breaks: true, + gfm: true, + renderer: { + // Code blocks: syntax highlight, no HTML escaping + code(token) { + const code = token.text; + const lang = token.lang; + let highlighted; + if (lang && hljs.getLanguage(lang)) { + try { + highlighted = hljs.highlight(code, { language: lang }).value; + } catch { + highlighted = escapeHtml(code); + } + } else { + // Auto-detect language if not specified + try { + highlighted = hljs.highlightAuto(code).value; + } catch { + highlighted = escapeHtml(code); + } + } + return `
${highlighted}
`; + }, + // Text content: escape HTML tags + text(token) { + return escapeHtmlTags(escapeHtml(token.text)); + }, + // Inline code: escape HTML + codespan(token) { + return `${escapeHtml(token.text)}`; + } + } + }); + + // Simple marked parse (escaping handled in renderers) + function safeMarkedParse(text) { + return marked.parse(text); + } + + // Search input + const searchInput = document.getElementById('tree-search'); + searchInput.addEventListener('input', (e) => { + searchQuery = e.target.value; + forceTreeRerender(); + }); + + // Filter buttons + document.querySelectorAll('.filter-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + filterMode = btn.dataset.filter; + forceTreeRerender(); + }); + }); + + // Sidebar toggle + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebar-overlay'); + const hamburger = document.getElementById('hamburger'); + + hamburger.addEventListener('click', () => { + sidebar.classList.add('open'); + overlay.classList.add('open'); + hamburger.style.display = 'none'; + }); + + const closeSidebar = () => { + sidebar.classList.remove('open'); + overlay.classList.remove('open'); + hamburger.style.display = ''; + }; + + overlay.addEventListener('click', closeSidebar); + document.getElementById('sidebar-close').addEventListener('click', closeSidebar); + + // Toggle states + let thinkingExpanded = true; + let toolOutputsExpanded = false; + + const toggleThinking = () => { + thinkingExpanded = !thinkingExpanded; + document.querySelectorAll('.thinking-text').forEach(el => { + el.style.display = thinkingExpanded ? '' : 'none'; + }); + document.querySelectorAll('.thinking-collapsed').forEach(el => { + el.style.display = thinkingExpanded ? 'none' : 'block'; + }); + }; + + const toggleToolOutputs = () => { + toolOutputsExpanded = !toolOutputsExpanded; + document.querySelectorAll('.tool-output.expandable').forEach(el => { + el.classList.toggle('expanded', toolOutputsExpanded); + }); + document.querySelectorAll('.compaction').forEach(el => { + el.classList.toggle('expanded', toolOutputsExpanded); + }); + }; + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + searchInput.value = ''; + searchQuery = ''; + navigateTo(leafId, 'bottom'); + } + if (e.ctrlKey && e.key === 't') { + e.preventDefault(); + toggleThinking(); + } + if (e.ctrlKey && e.key === 'o') { + e.preventDefault(); + toggleToolOutputs(); + } + }); + + // Initial render - don't scroll, stay at top + if (leafId) { + navigateTo(leafId, 'none'); + } else if (entries.length > 0) { + // Fallback: use last entry if no leafId + navigateTo(entries[entries.length - 1].id, 'none'); + } + })(); diff --git a/packages/coding-agent/src/core/export-html/vendor/highlight.min.js b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js new file mode 100644 index 00000000..5d699ae6 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/highlight.min.js @@ -0,0 +1,1213 @@ +/*! + Highlight.js v11.9.0 (git: f47103d4f1) + (c) 2006-2023 undefined and other contributors + License: BSD-3-Clause + */ +var hljs=function(){"use strict";function e(n){ +return n instanceof Map?n.clear=n.delete=n.set=()=>{ +throw Error("map is read-only")}:n instanceof Set&&(n.add=n.clear=n.delete=()=>{ +throw Error("set is read-only") +}),Object.freeze(n),Object.getOwnPropertyNames(n).forEach((t=>{ +const a=n[t],i=typeof a;"object"!==i&&"function"!==i||Object.isFrozen(a)||e(a) +})),n}class n{constructor(e){ +void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} +ignoreMatch(){this.isMatchIgnored=!0}}function t(e){ +return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'") +}function a(e,...n){const t=Object.create(null);for(const n in e)t[n]=e[n] +;return n.forEach((e=>{for(const n in e)t[n]=e[n]})),t}const i=e=>!!e.scope +;class r{constructor(e,n){ +this.buffer="",this.classPrefix=n.classPrefix,e.walk(this)}addText(e){ +this.buffer+=t(e)}openNode(e){if(!i(e))return;const n=((e,{prefix:n})=>{ +if(e.startsWith("language:"))return e.replace("language:","language-") +;if(e.includes(".")){const t=e.split(".") +;return[`${n}${t.shift()}`,...t.map(((e,n)=>`${e}${"_".repeat(n+1)}`))].join(" ") +}return`${n}${e}`})(e.scope,{prefix:this.classPrefix});this.span(n)} +closeNode(e){i(e)&&(this.buffer+="")}value(){return this.buffer}span(e){ +this.buffer+=``}}const s=(e={})=>{const n={children:[]} +;return Object.assign(n,e),n};class o{constructor(){ +this.rootNode=s(),this.stack=[this.rootNode]}get top(){ +return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ +this.top.children.push(e)}openNode(e){const n=s({scope:e}) +;this.add(n),this.stack.push(n)}closeNode(){ +if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ +for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} +walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,n){ +return"string"==typeof n?e.addText(n):n.children&&(e.openNode(n), +n.children.forEach((n=>this._walk(e,n))),e.closeNode(n)),e}static _collapse(e){ +"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ +o._collapse(e)})))}}class l extends o{constructor(e){super(),this.options=e} +addText(e){""!==e&&this.add(e)}startScope(e){this.openNode(e)}endScope(){ +this.closeNode()}__addSublanguage(e,n){const t=e.root +;n&&(t.scope="language:"+n),this.add(t)}toHTML(){ +return new r(this,this.options).value()}finalize(){ +return this.closeAllNodes(),!0}}function c(e){ +return e?"string"==typeof e?e:e.source:null}function d(e){return b("(?=",e,")")} +function g(e){return b("(?:",e,")*")}function u(e){return b("(?:",e,")?")} +function b(...e){return e.map((e=>c(e))).join("")}function m(...e){const n=(e=>{ +const n=e[e.length-1] +;return"object"==typeof n&&n.constructor===Object?(e.splice(e.length-1,1),n):{} +})(e);return"("+(n.capture?"":"?:")+e.map((e=>c(e))).join("|")+")"} +function p(e){return RegExp(e.toString()+"|").exec("").length-1} +const _=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./ +;function h(e,{joinWith:n}){let t=0;return e.map((e=>{t+=1;const n=t +;let a=c(e),i="";for(;a.length>0;){const e=_.exec(a);if(!e){i+=a;break} +i+=a.substring(0,e.index), +a=a.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?i+="\\"+(Number(e[1])+n):(i+=e[0], +"("===e[0]&&t++)}return i})).map((e=>`(${e})`)).join(n)} +const f="[a-zA-Z]\\w*",E="[a-zA-Z_]\\w*",y="\\b\\d+(\\.\\d+)?",N="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w="\\b(0b[01]+)",v={ +begin:"\\\\[\\s\\S]",relevance:0},O={scope:"string",begin:"'",end:"'", +illegal:"\\n",contains:[v]},k={scope:"string",begin:'"',end:'"',illegal:"\\n", +contains:[v]},x=(e,n,t={})=>{const i=a({scope:"comment",begin:e,end:n, +contains:[]},t);i.contains.push({scope:"doctag", +begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)", +end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0}) +;const r=m("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/) +;return i.contains.push({begin:b(/[ ]+/,"(",r,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i +},M=x("//","$"),S=x("/\\*","\\*/"),A=x("#","$");var C=Object.freeze({ +__proto__:null,APOS_STRING_MODE:O,BACKSLASH_ESCAPE:v,BINARY_NUMBER_MODE:{ +scope:"number",begin:w,relevance:0},BINARY_NUMBER_RE:w,COMMENT:x, +C_BLOCK_COMMENT_MODE:S,C_LINE_COMMENT_MODE:M,C_NUMBER_MODE:{scope:"number", +begin:N,relevance:0},C_NUMBER_RE:N,END_SAME_AS_BEGIN:e=>Object.assign(e,{ +"on:begin":(e,n)=>{n.data._beginMatch=e[1]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}}),HASH_COMMENT_MODE:A,IDENT_RE:f, +MATCH_NOTHING_RE:/\b\B/,METHOD_GUARD:{begin:"\\.\\s*"+E,relevance:0}, +NUMBER_MODE:{scope:"number",begin:y,relevance:0},NUMBER_RE:y, +PHRASAL_WORDS_MODE:{ +begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/ +},QUOTE_STRING_MODE:k,REGEXP_MODE:{scope:"regexp",begin:/\/(?=[^/\n]*\/)/, +end:/\/[gimuy]*/,contains:[v,{begin:/\[/,end:/\]/,relevance:0,contains:[v]}]}, +RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", +SHEBANG:(e={})=>{const n=/^#![ ]*\// +;return e.binary&&(e.begin=b(n,/.*\b/,e.binary,/\b.*/)),a({scope:"meta",begin:n, +end:/$/,relevance:0,"on:begin":(e,n)=>{0!==e.index&&n.ignoreMatch()}},e)}, +TITLE_MODE:{scope:"title",begin:f,relevance:0},UNDERSCORE_IDENT_RE:E, +UNDERSCORE_TITLE_MODE:{scope:"title",begin:E,relevance:0}});function T(e,n){ +"."===e.input[e.index-1]&&n.ignoreMatch()}function R(e,n){ +void 0!==e.className&&(e.scope=e.className,delete e.className)}function D(e,n){ +n&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", +e.__beforeBegin=T,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, +void 0===e.relevance&&(e.relevance=0))}function I(e,n){ +Array.isArray(e.illegal)&&(e.illegal=m(...e.illegal))}function L(e,n){ +if(e.match){ +if(e.begin||e.end)throw Error("begin & end are not supported with match") +;e.begin=e.match,delete e.match}}function B(e,n){ +void 0===e.relevance&&(e.relevance=1)}const $=(e,n)=>{if(!e.beforeMatch)return +;if(e.starts)throw Error("beforeMatch cannot be used with starts") +;const t=Object.assign({},e);Object.keys(e).forEach((n=>{delete e[n] +})),e.keywords=t.keywords,e.begin=b(t.beforeMatch,d(t.begin)),e.starts={ +relevance:0,contains:[Object.assign(t,{endsParent:!0})] +},e.relevance=0,delete t.beforeMatch +},z=["of","and","for","in","not","or","if","then","parent","list","value"],F="keyword" +;function U(e,n,t=F){const a=Object.create(null) +;return"string"==typeof e?i(t,e.split(" ")):Array.isArray(e)?i(t,e):Object.keys(e).forEach((t=>{ +Object.assign(a,U(e[t],n,t))})),a;function i(e,t){ +n&&(t=t.map((e=>e.toLowerCase()))),t.forEach((n=>{const t=n.split("|") +;a[t[0]]=[e,j(t[0],t[1])]}))}}function j(e,n){ +return n?Number(n):(e=>z.includes(e.toLowerCase()))(e)?0:1}const P={},K=e=>{ +console.error(e)},H=(e,...n)=>{console.log("WARN: "+e,...n)},q=(e,n)=>{ +P[`${e}/${n}`]||(console.log(`Deprecated as of ${e}. ${n}`),P[`${e}/${n}`]=!0) +},G=Error();function Z(e,n,{key:t}){let a=0;const i=e[t],r={},s={} +;for(let e=1;e<=n.length;e++)s[e+a]=i[e],r[e+a]=!0,a+=p(n[e-1]) +;e[t]=s,e[t]._emit=r,e[t]._multi=!0}function W(e){(e=>{ +e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope, +delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={ +_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope +}),(e=>{if(Array.isArray(e.begin)){ +if(e.skip||e.excludeBegin||e.returnBegin)throw K("skip, excludeBegin, returnBegin not compatible with beginScope: {}"), +G +;if("object"!=typeof e.beginScope||null===e.beginScope)throw K("beginScope must be object"), +G;Z(e,e.begin,{key:"beginScope"}),e.begin=h(e.begin,{joinWith:""})}})(e),(e=>{ +if(Array.isArray(e.end)){ +if(e.skip||e.excludeEnd||e.returnEnd)throw K("skip, excludeEnd, returnEnd not compatible with endScope: {}"), +G +;if("object"!=typeof e.endScope||null===e.endScope)throw K("endScope must be object"), +G;Z(e,e.end,{key:"endScope"}),e.end=h(e.end,{joinWith:""})}})(e)}function Q(e){ +function n(n,t){ +return RegExp(c(n),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(t?"g":"")) +}class t{constructor(){ +this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} +addRule(e,n){ +n.position=this.position++,this.matchIndexes[this.matchAt]=n,this.regexes.push([n,e]), +this.matchAt+=p(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null) +;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(h(e,{joinWith:"|" +}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex +;const n=this.matcherRe.exec(e);if(!n)return null +;const t=n.findIndex(((e,n)=>n>0&&void 0!==e)),a=this.matchIndexes[t] +;return n.splice(0,t),Object.assign(n,a)}}class i{constructor(){ +this.rules=[],this.multiRegexes=[], +this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ +if(this.multiRegexes[e])return this.multiRegexes[e];const n=new t +;return this.rules.slice(e).forEach((([e,t])=>n.addRule(e,t))), +n.compile(),this.multiRegexes[e]=n,n}resumingScanAtSamePosition(){ +return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,n){ +this.rules.push([e,n]),"begin"===n.type&&this.count++}exec(e){ +const n=this.getMatcher(this.regexIndex);n.lastIndex=this.lastIndex +;let t=n.exec(e) +;if(this.resumingScanAtSamePosition())if(t&&t.index===this.lastIndex);else{ +const n=this.getMatcher(0);n.lastIndex=this.lastIndex+1,t=n.exec(e)} +return t&&(this.regexIndex+=t.position+1, +this.regexIndex===this.count&&this.considerAll()),t}} +if(e.compilerExtensions||(e.compilerExtensions=[]), +e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") +;return e.classNameAliases=a(e.classNameAliases||{}),function t(r,s){const o=r +;if(r.isCompiled)return o +;[R,L,W,$].forEach((e=>e(r,s))),e.compilerExtensions.forEach((e=>e(r,s))), +r.__beforeBegin=null,[D,I,B].forEach((e=>e(r,s))),r.isCompiled=!0;let l=null +;return"object"==typeof r.keywords&&r.keywords.$pattern&&(r.keywords=Object.assign({},r.keywords), +l=r.keywords.$pattern, +delete r.keywords.$pattern),l=l||/\w+/,r.keywords&&(r.keywords=U(r.keywords,e.case_insensitive)), +o.keywordPatternRe=n(l,!0), +s&&(r.begin||(r.begin=/\B|\b/),o.beginRe=n(o.begin),r.end||r.endsWithParent||(r.end=/\B|\b/), +r.end&&(o.endRe=n(o.end)), +o.terminatorEnd=c(o.end)||"",r.endsWithParent&&s.terminatorEnd&&(o.terminatorEnd+=(r.end?"|":"")+s.terminatorEnd)), +r.illegal&&(o.illegalRe=n(r.illegal)), +r.contains||(r.contains=[]),r.contains=[].concat(...r.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((n=>a(e,{ +variants:null},n)))),e.cachedVariants?e.cachedVariants:X(e)?a(e,{ +starts:e.starts?a(e.starts):null +}):Object.isFrozen(e)?a(e):e))("self"===e?r:e)))),r.contains.forEach((e=>{t(e,o) +})),r.starts&&t(r.starts,s),o.matcher=(e=>{const n=new i +;return e.contains.forEach((e=>n.addRule(e.begin,{rule:e,type:"begin" +}))),e.terminatorEnd&&n.addRule(e.terminatorEnd,{type:"end" +}),e.illegal&&n.addRule(e.illegal,{type:"illegal"}),n})(o),o}(e)}function X(e){ +return!!e&&(e.endsWithParent||X(e.starts))}class V extends Error{ +constructor(e,n){super(e),this.name="HTMLInjectionError",this.html=n}} +const J=t,Y=a,ee=Symbol("nomatch"),ne=t=>{ +const a=Object.create(null),i=Object.create(null),r=[];let s=!0 +;const o="Could not find the language '{}', did you forget to load/include a language module?",c={ +disableAutodetect:!0,name:"Plain text",contains:[]};let p={ +ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i, +languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", +cssSelector:"pre code",languages:null,__emitter:l};function _(e){ +return p.noHighlightRe.test(e)}function h(e,n,t){let a="",i="" +;"object"==typeof n?(a=e, +t=n.ignoreIllegals,i=n.language):(q("10.7.0","highlight(lang, code, ...args) has been deprecated."), +q("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), +i=e,a=n),void 0===t&&(t=!0);const r={code:a,language:i};x("before:highlight",r) +;const s=r.result?r.result:f(r.language,r.code,t) +;return s.code=r.code,x("after:highlight",s),s}function f(e,t,i,r){ +const l=Object.create(null);function c(){if(!x.keywords)return void S.addText(A) +;let e=0;x.keywordPatternRe.lastIndex=0;let n=x.keywordPatternRe.exec(A),t="" +;for(;n;){t+=A.substring(e,n.index) +;const i=w.case_insensitive?n[0].toLowerCase():n[0],r=(a=i,x.keywords[a]);if(r){ +const[e,a]=r +;if(S.addText(t),t="",l[i]=(l[i]||0)+1,l[i]<=7&&(C+=a),e.startsWith("_"))t+=n[0];else{ +const t=w.classNameAliases[e]||e;g(n[0],t)}}else t+=n[0] +;e=x.keywordPatternRe.lastIndex,n=x.keywordPatternRe.exec(A)}var a +;t+=A.substring(e),S.addText(t)}function d(){null!=x.subLanguage?(()=>{ +if(""===A)return;let e=null;if("string"==typeof x.subLanguage){ +if(!a[x.subLanguage])return void S.addText(A) +;e=f(x.subLanguage,A,!0,M[x.subLanguage]),M[x.subLanguage]=e._top +}else e=E(A,x.subLanguage.length?x.subLanguage:null) +;x.relevance>0&&(C+=e.relevance),S.__addSublanguage(e._emitter,e.language) +})():c(),A=""}function g(e,n){ +""!==e&&(S.startScope(n),S.addText(e),S.endScope())}function u(e,n){let t=1 +;const a=n.length-1;for(;t<=a;){if(!e._emit[t]){t++;continue} +const a=w.classNameAliases[e[t]]||e[t],i=n[t];a?g(i,a):(A=i,c(),A=""),t++}} +function b(e,n){ +return e.scope&&"string"==typeof e.scope&&S.openNode(w.classNameAliases[e.scope]||e.scope), +e.beginScope&&(e.beginScope._wrap?(g(A,w.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap), +A=""):e.beginScope._multi&&(u(e.beginScope,n),A="")),x=Object.create(e,{parent:{ +value:x}}),x}function m(e,t,a){let i=((e,n)=>{const t=e&&e.exec(n) +;return t&&0===t.index})(e.endRe,a);if(i){if(e["on:end"]){const a=new n(e) +;e["on:end"](t,a),a.isMatchIgnored&&(i=!1)}if(i){ +for(;e.endsParent&&e.parent;)e=e.parent;return e}} +if(e.endsWithParent)return m(e.parent,t,a)}function _(e){ +return 0===x.matcher.regexIndex?(A+=e[0],1):(D=!0,0)}function h(e){ +const n=e[0],a=t.substring(e.index),i=m(x,e,a);if(!i)return ee;const r=x +;x.endScope&&x.endScope._wrap?(d(), +g(n,x.endScope._wrap)):x.endScope&&x.endScope._multi?(d(), +u(x.endScope,e)):r.skip?A+=n:(r.returnEnd||r.excludeEnd||(A+=n), +d(),r.excludeEnd&&(A=n));do{ +x.scope&&S.closeNode(),x.skip||x.subLanguage||(C+=x.relevance),x=x.parent +}while(x!==i.parent);return i.starts&&b(i.starts,e),r.returnEnd?0:n.length} +let y={};function N(a,r){const o=r&&r[0];if(A+=a,null==o)return d(),0 +;if("begin"===y.type&&"end"===r.type&&y.index===r.index&&""===o){ +if(A+=t.slice(r.index,r.index+1),!s){const n=Error(`0 width match regex (${e})`) +;throw n.languageName=e,n.badRule=y.rule,n}return 1} +if(y=r,"begin"===r.type)return(e=>{ +const t=e[0],a=e.rule,i=new n(a),r=[a.__beforeBegin,a["on:begin"]] +;for(const n of r)if(n&&(n(e,i),i.isMatchIgnored))return _(t) +;return a.skip?A+=t:(a.excludeBegin&&(A+=t), +d(),a.returnBegin||a.excludeBegin||(A=t)),b(a,e),a.returnBegin?0:t.length})(r) +;if("illegal"===r.type&&!i){ +const e=Error('Illegal lexeme "'+o+'" for mode "'+(x.scope||"")+'"') +;throw e.mode=x,e}if("end"===r.type){const e=h(r);if(e!==ee)return e} +if("illegal"===r.type&&""===o)return 1 +;if(R>1e5&&R>3*r.index)throw Error("potential infinite loop, way more iterations than matches") +;return A+=o,o.length}const w=v(e) +;if(!w)throw K(o.replace("{}",e)),Error('Unknown language: "'+e+'"') +;const O=Q(w);let k="",x=r||O;const M={},S=new p.__emitter(p);(()=>{const e=[] +;for(let n=x;n!==w;n=n.parent)n.scope&&e.unshift(n.scope) +;e.forEach((e=>S.openNode(e)))})();let A="",C=0,T=0,R=0,D=!1;try{ +if(w.__emitTokens)w.__emitTokens(t,S);else{for(x.matcher.considerAll();;){ +R++,D?D=!1:x.matcher.considerAll(),x.matcher.lastIndex=T +;const e=x.matcher.exec(t);if(!e)break;const n=N(t.substring(T,e.index),e) +;T=e.index+n}N(t.substring(T))}return S.finalize(),k=S.toHTML(),{language:e, +value:k,relevance:C,illegal:!1,_emitter:S,_top:x}}catch(n){ +if(n.message&&n.message.includes("Illegal"))return{language:e,value:J(t), +illegal:!0,relevance:0,_illegalBy:{message:n.message,index:T, +context:t.slice(T-100,T+100),mode:n.mode,resultSoFar:k},_emitter:S};if(s)return{ +language:e,value:J(t),illegal:!1,relevance:0,errorRaised:n,_emitter:S,_top:x} +;throw n}}function E(e,n){n=n||p.languages||Object.keys(a);const t=(e=>{ +const n={value:J(e),illegal:!1,relevance:0,_top:c,_emitter:new p.__emitter(p)} +;return n._emitter.addText(e),n})(e),i=n.filter(v).filter(k).map((n=>f(n,e,!1))) +;i.unshift(t);const r=i.sort(((e,n)=>{ +if(e.relevance!==n.relevance)return n.relevance-e.relevance +;if(e.language&&n.language){if(v(e.language).supersetOf===n.language)return 1 +;if(v(n.language).supersetOf===e.language)return-1}return 0})),[s,o]=r,l=s +;return l.secondBest=o,l}function y(e){let n=null;const t=(e=>{ +let n=e.className+" ";n+=e.parentNode?e.parentNode.className:"" +;const t=p.languageDetectRe.exec(n);if(t){const n=v(t[1]) +;return n||(H(o.replace("{}",t[1])), +H("Falling back to no-highlight mode for this block.",e)),n?t[1]:"no-highlight"} +return n.split(/\s+/).find((e=>_(e)||v(e)))})(e);if(_(t))return +;if(x("before:highlightElement",{el:e,language:t +}),e.dataset.highlighted)return void console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",e) +;if(e.children.length>0&&(p.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."), +console.warn("https://github.com/highlightjs/highlight.js/wiki/security"), +console.warn("The element with unescaped HTML:"), +console.warn(e)),p.throwUnescapedHTML))throw new V("One of your code blocks includes unescaped HTML.",e.innerHTML) +;n=e;const a=n.textContent,r=t?h(a,{language:t,ignoreIllegals:!0}):E(a) +;e.innerHTML=r.value,e.dataset.highlighted="yes",((e,n,t)=>{const a=n&&i[n]||t +;e.classList.add("hljs"),e.classList.add("language-"+a) +})(e,t,r.language),e.result={language:r.language,re:r.relevance, +relevance:r.relevance},r.secondBest&&(e.secondBest={ +language:r.secondBest.language,relevance:r.secondBest.relevance +}),x("after:highlightElement",{el:e,result:r,text:a})}let N=!1;function w(){ +"loading"!==document.readyState?document.querySelectorAll(p.cssSelector).forEach(y):N=!0 +}function v(e){return e=(e||"").toLowerCase(),a[e]||a[i[e]]} +function O(e,{languageName:n}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ +i[e.toLowerCase()]=n}))}function k(e){const n=v(e) +;return n&&!n.disableAutodetect}function x(e,n){const t=e;r.forEach((e=>{ +e[t]&&e[t](n)}))} +"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ +N&&w()}),!1),Object.assign(t,{highlight:h,highlightAuto:E,highlightAll:w, +highlightElement:y, +highlightBlock:e=>(q("10.7.0","highlightBlock will be removed entirely in v12.0"), +q("10.7.0","Please use highlightElement now."),y(e)),configure:e=>{p=Y(p,e)}, +initHighlighting:()=>{ +w(),q("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")}, +initHighlightingOnLoad:()=>{ +w(),q("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.") +},registerLanguage:(e,n)=>{let i=null;try{i=n(t)}catch(n){ +if(K("Language definition for '{}' could not be registered.".replace("{}",e)), +!s)throw n;K(n),i=c} +i.name||(i.name=e),a[e]=i,i.rawDefinition=n.bind(null,t),i.aliases&&O(i.aliases,{ +languageName:e})},unregisterLanguage:e=>{delete a[e] +;for(const n of Object.keys(i))i[n]===e&&delete i[n]}, +listLanguages:()=>Object.keys(a),getLanguage:v,registerAliases:O, +autoDetection:k,inherit:Y,addPlugin:e=>{(e=>{ +e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=n=>{ +e["before:highlightBlock"](Object.assign({block:n.el},n)) +}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=n=>{ +e["after:highlightBlock"](Object.assign({block:n.el},n))})})(e),r.push(e)}, +removePlugin:e=>{const n=r.indexOf(e);-1!==n&&r.splice(n,1)}}),t.debugMode=()=>{ +s=!1},t.safeMode=()=>{s=!0},t.versionString="11.9.0",t.regex={concat:b, +lookahead:d,either:m,optional:u,anyNumberOfTimes:g} +;for(const n in C)"object"==typeof C[n]&&e(C[n]);return Object.assign(t,C),t +},te=ne({});te.newInstance=()=>ne({});var ae=te;const ie=e=>({IMPORTANT:{ +scope:"meta",begin:"!important"},BLOCK_COMMENT:e.C_BLOCK_COMMENT_MODE,HEXCOLOR:{ +scope:"number",begin:/#(([0-9a-fA-F]{3,4})|(([0-9a-fA-F]{2}){3,4}))\b/}, +FUNCTION_DISPATCH:{className:"built_in",begin:/[\w-]+(?=\()/}, +ATTRIBUTE_SELECTOR_MODE:{scope:"selector-attr",begin:/\[/,end:/\]/,illegal:"$", +contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},CSS_NUMBER_MODE:{ +scope:"number", +begin:e.NUMBER_RE+"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?", +relevance:0},CSS_VARIABLE:{className:"attr",begin:/--[A-Za-z_][A-Za-z0-9_-]*/} +}),re=["a","abbr","address","article","aside","audio","b","blockquote","body","button","canvas","caption","cite","code","dd","del","details","dfn","div","dl","dt","em","fieldset","figcaption","figure","footer","form","h1","h2","h3","h4","h5","h6","header","hgroup","html","i","iframe","img","input","ins","kbd","label","legend","li","main","mark","menu","nav","object","ol","p","q","quote","samp","section","span","strong","summary","sup","table","tbody","td","textarea","tfoot","th","thead","time","tr","ul","var","video"],se=["any-hover","any-pointer","aspect-ratio","color","color-gamut","color-index","device-aspect-ratio","device-height","device-width","display-mode","forced-colors","grid","height","hover","inverted-colors","monochrome","orientation","overflow-block","overflow-inline","pointer","prefers-color-scheme","prefers-contrast","prefers-reduced-motion","prefers-reduced-transparency","resolution","scan","scripting","update","width","min-width","max-width","min-height","max-height"],oe=["active","any-link","blank","checked","current","default","defined","dir","disabled","drop","empty","enabled","first","first-child","first-of-type","fullscreen","future","focus","focus-visible","focus-within","has","host","host-context","hover","indeterminate","in-range","invalid","is","lang","last-child","last-of-type","left","link","local-link","not","nth-child","nth-col","nth-last-child","nth-last-col","nth-last-of-type","nth-of-type","only-child","only-of-type","optional","out-of-range","past","placeholder-shown","read-only","read-write","required","right","root","scope","target","target-within","user-invalid","valid","visited","where"],le=["after","backdrop","before","cue","cue-region","first-letter","first-line","grammar-error","marker","part","placeholder","selection","slotted","spelling-error"],ce=["align-content","align-items","align-self","all","animation","animation-delay","animation-direction","animation-duration","animation-fill-mode","animation-iteration-count","animation-name","animation-play-state","animation-timing-function","backface-visibility","background","background-attachment","background-blend-mode","background-clip","background-color","background-image","background-origin","background-position","background-repeat","background-size","block-size","border","border-block","border-block-color","border-block-end","border-block-end-color","border-block-end-style","border-block-end-width","border-block-start","border-block-start-color","border-block-start-style","border-block-start-width","border-block-style","border-block-width","border-bottom","border-bottom-color","border-bottom-left-radius","border-bottom-right-radius","border-bottom-style","border-bottom-width","border-collapse","border-color","border-image","border-image-outset","border-image-repeat","border-image-slice","border-image-source","border-image-width","border-inline","border-inline-color","border-inline-end","border-inline-end-color","border-inline-end-style","border-inline-end-width","border-inline-start","border-inline-start-color","border-inline-start-style","border-inline-start-width","border-inline-style","border-inline-width","border-left","border-left-color","border-left-style","border-left-width","border-radius","border-right","border-right-color","border-right-style","border-right-width","border-spacing","border-style","border-top","border-top-color","border-top-left-radius","border-top-right-radius","border-top-style","border-top-width","border-width","bottom","box-decoration-break","box-shadow","box-sizing","break-after","break-before","break-inside","caption-side","caret-color","clear","clip","clip-path","clip-rule","color","column-count","column-fill","column-gap","column-rule","column-rule-color","column-rule-style","column-rule-width","column-span","column-width","columns","contain","content","content-visibility","counter-increment","counter-reset","cue","cue-after","cue-before","cursor","direction","display","empty-cells","filter","flex","flex-basis","flex-direction","flex-flow","flex-grow","flex-shrink","flex-wrap","float","flow","font","font-display","font-family","font-feature-settings","font-kerning","font-language-override","font-size","font-size-adjust","font-smoothing","font-stretch","font-style","font-synthesis","font-variant","font-variant-caps","font-variant-east-asian","font-variant-ligatures","font-variant-numeric","font-variant-position","font-variation-settings","font-weight","gap","glyph-orientation-vertical","grid","grid-area","grid-auto-columns","grid-auto-flow","grid-auto-rows","grid-column","grid-column-end","grid-column-start","grid-gap","grid-row","grid-row-end","grid-row-start","grid-template","grid-template-areas","grid-template-columns","grid-template-rows","hanging-punctuation","height","hyphens","icon","image-orientation","image-rendering","image-resolution","ime-mode","inline-size","isolation","justify-content","left","letter-spacing","line-break","line-height","list-style","list-style-image","list-style-position","list-style-type","margin","margin-block","margin-block-end","margin-block-start","margin-bottom","margin-inline","margin-inline-end","margin-inline-start","margin-left","margin-right","margin-top","marks","mask","mask-border","mask-border-mode","mask-border-outset","mask-border-repeat","mask-border-slice","mask-border-source","mask-border-width","mask-clip","mask-composite","mask-image","mask-mode","mask-origin","mask-position","mask-repeat","mask-size","mask-type","max-block-size","max-height","max-inline-size","max-width","min-block-size","min-height","min-inline-size","min-width","mix-blend-mode","nav-down","nav-index","nav-left","nav-right","nav-up","none","normal","object-fit","object-position","opacity","order","orphans","outline","outline-color","outline-offset","outline-style","outline-width","overflow","overflow-wrap","overflow-x","overflow-y","padding","padding-block","padding-block-end","padding-block-start","padding-bottom","padding-inline","padding-inline-end","padding-inline-start","padding-left","padding-right","padding-top","page-break-after","page-break-before","page-break-inside","pause","pause-after","pause-before","perspective","perspective-origin","pointer-events","position","quotes","resize","rest","rest-after","rest-before","right","row-gap","scroll-margin","scroll-margin-block","scroll-margin-block-end","scroll-margin-block-start","scroll-margin-bottom","scroll-margin-inline","scroll-margin-inline-end","scroll-margin-inline-start","scroll-margin-left","scroll-margin-right","scroll-margin-top","scroll-padding","scroll-padding-block","scroll-padding-block-end","scroll-padding-block-start","scroll-padding-bottom","scroll-padding-inline","scroll-padding-inline-end","scroll-padding-inline-start","scroll-padding-left","scroll-padding-right","scroll-padding-top","scroll-snap-align","scroll-snap-stop","scroll-snap-type","scrollbar-color","scrollbar-gutter","scrollbar-width","shape-image-threshold","shape-margin","shape-outside","speak","speak-as","src","tab-size","table-layout","text-align","text-align-all","text-align-last","text-combine-upright","text-decoration","text-decoration-color","text-decoration-line","text-decoration-style","text-emphasis","text-emphasis-color","text-emphasis-position","text-emphasis-style","text-indent","text-justify","text-orientation","text-overflow","text-rendering","text-shadow","text-transform","text-underline-position","top","transform","transform-box","transform-origin","transform-style","transition","transition-delay","transition-duration","transition-property","transition-timing-function","unicode-bidi","vertical-align","visibility","voice-balance","voice-duration","voice-family","voice-pitch","voice-range","voice-rate","voice-stress","voice-volume","white-space","widows","width","will-change","word-break","word-spacing","word-wrap","writing-mode","z-index"].reverse(),de=oe.concat(le) +;var ge="[0-9](_*[0-9])*",ue=`\\.(${ge})`,be="[0-9a-fA-F](_*[0-9a-fA-F])*",me={ +className:"number",variants:[{ +begin:`(\\b(${ge})((${ue})|\\.)?|(${ue}))[eE][+-]?(${ge})[fFdD]?\\b`},{ +begin:`\\b(${ge})((${ue})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ +begin:`(${ue})[fFdD]?\\b`},{begin:`\\b(${ge})[fFdD]\\b`},{ +begin:`\\b0[xX]((${be})\\.?|(${be})?\\.(${be}))[pP][+-]?(${ge})[fFdD]?\\b`},{ +begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${be})[lL]?\\b`},{ +begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], +relevance:0};function pe(e,n,t){return-1===t?"":e.replace(n,(a=>pe(e,n,t-1)))} +const _e="[A-Za-z$_][0-9A-Za-z$_]*",he=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends"],fe=["true","false","null","undefined","NaN","Infinity"],Ee=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ye=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],Ne=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],we=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],ve=[].concat(Ne,Ee,ye) +;function Oe(e){const n=e.regex,t=_e,a={begin:/<[A-Za-z0-9\\._:-]+/, +end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ +const t=e[0].length+e.index,a=e.input[t] +;if("<"===a||","===a)return void n.ignoreMatch();let i +;">"===a&&(((e,{after:n})=>{const t="",M={ +match:[/const|var|let/,/\s+/,t,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(x)], +keywords:"async",className:{1:"keyword",3:"title.function"},contains:[f]} +;return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:i,exports:{ +PARAMS_CONTAINS:h,CLASS_REFERENCE:y},illegal:/#(?![$_A-z])/, +contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{ +label:"use_strict",className:"meta",relevance:10, +begin:/^\s*['"]use (strict|asm)['"]/ +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,d,g,u,b,m,{match:/\$\d+/},l,y,{ +className:"attr",begin:t+n.lookahead(":"),relevance:0},M,{ +begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", +keywords:"return throw case",relevance:0,contains:[m,e.REGEXP_MODE,{ +className:"function",begin:x,returnBegin:!0,end:"\\s*=>",contains:[{ +className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{ +className:null,begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0, +excludeEnd:!0,keywords:i,contains:h}]}]},{begin:/,/,relevance:0},{match:/\s+/, +relevance:0},{variants:[{begin:"<>",end:""},{ +match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:a.begin, +"on:begin":a.isTrulyOpeningTag,end:a.end}],subLanguage:"xml",contains:[{ +begin:a.begin,end:a.end,skip:!0,contains:["self"]}]}]},N,{ +beginKeywords:"while if switch catch for"},{ +begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", +returnBegin:!0,label:"func.def",contains:[f,e.inherit(e.TITLE_MODE,{begin:t, +className:"title.function"})]},{match:/\.\.\./,relevance:0},O,{match:"\\$"+t, +relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"}, +contains:[f]},w,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},E,k,{match:/\$[(.]/}]}} +const ke=e=>b(/\b/,e,/\w$/.test(e)?/\b/:/\B/),xe=["Protocol","Type"].map(ke),Me=["init","self"].map(ke),Se=["Any","Self"],Ae=["actor","any","associatedtype","async","await",/as\?/,/as!/,"as","borrowing","break","case","catch","class","consume","consuming","continue","convenience","copy","default","defer","deinit","didSet","distributed","do","dynamic","each","else","enum","extension","fallthrough",/fileprivate\(set\)/,"fileprivate","final","for","func","get","guard","if","import","indirect","infix",/init\?/,/init!/,"inout",/internal\(set\)/,"internal","in","is","isolated","nonisolated","lazy","let","macro","mutating","nonmutating",/open\(set\)/,"open","operator","optional","override","postfix","precedencegroup","prefix",/private\(set\)/,"private","protocol",/public\(set\)/,"public","repeat","required","rethrows","return","set","some","static","struct","subscript","super","switch","throws","throw",/try\?/,/try!/,"try","typealias",/unowned\(safe\)/,/unowned\(unsafe\)/,"unowned","var","weak","where","while","willSet"],Ce=["false","nil","true"],Te=["assignment","associativity","higherThan","left","lowerThan","none","right"],Re=["#colorLiteral","#column","#dsohandle","#else","#elseif","#endif","#error","#file","#fileID","#fileLiteral","#filePath","#function","#if","#imageLiteral","#keyPath","#line","#selector","#sourceLocation","#warning"],De=["abs","all","any","assert","assertionFailure","debugPrint","dump","fatalError","getVaList","isKnownUniquelyReferenced","max","min","numericCast","pointwiseMax","pointwiseMin","precondition","preconditionFailure","print","readLine","repeatElement","sequence","stride","swap","swift_unboxFromSwiftValueWithType","transcode","type","unsafeBitCast","unsafeDowncast","withExtendedLifetime","withUnsafeMutablePointer","withUnsafePointer","withVaList","withoutActuallyEscaping","zip"],Ie=m(/[/=\-+!*%<>&|^~?]/,/[\u00A1-\u00A7]/,/[\u00A9\u00AB]/,/[\u00AC\u00AE]/,/[\u00B0\u00B1]/,/[\u00B6\u00BB\u00BF\u00D7\u00F7]/,/[\u2016-\u2017]/,/[\u2020-\u2027]/,/[\u2030-\u203E]/,/[\u2041-\u2053]/,/[\u2055-\u205E]/,/[\u2190-\u23FF]/,/[\u2500-\u2775]/,/[\u2794-\u2BFF]/,/[\u2E00-\u2E7F]/,/[\u3001-\u3003]/,/[\u3008-\u3020]/,/[\u3030]/),Le=m(Ie,/[\u0300-\u036F]/,/[\u1DC0-\u1DFF]/,/[\u20D0-\u20FF]/,/[\uFE00-\uFE0F]/,/[\uFE20-\uFE2F]/),Be=b(Ie,Le,"*"),$e=m(/[a-zA-Z_]/,/[\u00A8\u00AA\u00AD\u00AF\u00B2-\u00B5\u00B7-\u00BA]/,/[\u00BC-\u00BE\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF]/,/[\u0100-\u02FF\u0370-\u167F\u1681-\u180D\u180F-\u1DBF]/,/[\u1E00-\u1FFF]/,/[\u200B-\u200D\u202A-\u202E\u203F-\u2040\u2054\u2060-\u206F]/,/[\u2070-\u20CF\u2100-\u218F\u2460-\u24FF\u2776-\u2793]/,/[\u2C00-\u2DFF\u2E80-\u2FFF]/,/[\u3004-\u3007\u3021-\u302F\u3031-\u303F\u3040-\uD7FF]/,/[\uF900-\uFD3D\uFD40-\uFDCF\uFDF0-\uFE1F\uFE30-\uFE44]/,/[\uFE47-\uFEFE\uFF00-\uFFFD]/),ze=m($e,/\d/,/[\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/),Fe=b($e,ze,"*"),Ue=b(/[A-Z]/,ze,"*"),je=["attached","autoclosure",b(/convention\(/,m("swift","block","c"),/\)/),"discardableResult","dynamicCallable","dynamicMemberLookup","escaping","freestanding","frozen","GKInspectable","IBAction","IBDesignable","IBInspectable","IBOutlet","IBSegueAction","inlinable","main","nonobjc","NSApplicationMain","NSCopying","NSManaged",b(/objc\(/,Fe,/\)/),"objc","objcMembers","propertyWrapper","requires_stored_property_inits","resultBuilder","Sendable","testable","UIApplicationMain","unchecked","unknown","usableFromInline","warn_unqualified_access"],Pe=["iOS","iOSApplicationExtension","macOS","macOSApplicationExtension","macCatalyst","macCatalystApplicationExtension","watchOS","watchOSApplicationExtension","tvOS","tvOSApplicationExtension","swift"] +;var Ke=Object.freeze({__proto__:null,grmr_bash:e=>{const n=e.regex,t={},a={ +begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]} +;Object.assign(t,{className:"variable",variants:[{ +begin:n.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},a]});const i={ +className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},r={ +begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/, +end:/(\w+)/,className:"string"})]}},s={className:"string",begin:/"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,t,i]};i.contains.push(s);const o={begin:/\$?\(\(/, +end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t] +},l=e.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 +}),c={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, +contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ +name:"Bash",aliases:["sh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/, +keyword:["if","then","else","elif","fi","for","while","until","in","do","done","case","esac","function","select"], +literal:["true","false"], +built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"] +},contains:[l,e.SHEBANG(),c,o,e.HASH_COMMENT_MODE,r,{match:/(\/[a-z._-]+)+/},s,{ +match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}, +grmr_c:e=>{const n=e.regex,t=e.COMMENT("//","$",{contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",variants:[{begin:"\\b[a-z\\d_]*_t\\b"},{ +match:/\batomic_[a-z]{3,6}\b/}]},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +keyword:["asm","auto","break","case","continue","default","do","else","enum","extern","for","fortran","goto","if","inline","register","restrict","return","sizeof","struct","switch","typedef","union","volatile","while","_Alignas","_Alignof","_Atomic","_Generic","_Noreturn","_Static_assert","_Thread_local","alignas","alignof","noreturn","static_assert","thread_local","_Pragma"], +type:["float","double","signed","unsigned","int","short","long","char","void","_Bool","_Complex","_Imaginary","_Decimal32","_Decimal64","_Decimal128","const","static","complex","bool","imaginary"], +literal:"true false NULL", +built_in:"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr" +},b=[c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],m={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:b.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:b.concat(["self"]),relevance:0}]),relevance:0},p={ +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[e.inherit(d,{className:"title.function"})], +relevance:0},{relevance:0,match:/,/},{className:"params",begin:/\(/,end:/\)/, +keywords:u,relevance:0,contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/, +end:/\)/,keywords:u,relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s] +}]},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, +disableAutodetect:!0,illegal:"=]/,contains:[{ +beginKeywords:"final class struct"},e.TITLE_MODE]}]),exports:{preprocessor:c, +strings:o,keywords:u}}},grmr_cpp:e=>{const n=e.regex,t=e.COMMENT("//","$",{ +contains:[{begin:/\\\n/}] +}),a="decltype\\(auto\\)",i="[a-zA-Z_]\\w*::",r="(?!struct)("+a+"|"+n.optional(i)+"[a-zA-Z_]\\w*"+n.optional("<[^<>]+>")+")",s={ +className:"type",begin:"\\b[a-z\\d_]*_t\\b"},o={className:"string",variants:[{ +begin:'(u8?|U|L)?"',end:'"',illegal:"\\n",contains:[e.BACKSLASH_ESCAPE]},{ +begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", +end:"'",illegal:"."},e.END_SAME_AS_BEGIN({ +begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},l={ +className:"number",variants:[{begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" +},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ +keyword:"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" +},contains:[{begin:/\\\n/,relevance:0},e.inherit(o,{className:"string"}),{ +className:"string",begin:/<.*?>/},t,e.C_BLOCK_COMMENT_MODE]},d={ +className:"title",begin:n.optional(i)+e.IDENT_RE,relevance:0 +},g=n.optional(i)+e.IDENT_RE+"\\s*\\(",u={ +type:["bool","char","char16_t","char32_t","char8_t","double","float","int","long","short","void","wchar_t","unsigned","signed","const","static"], +keyword:["alignas","alignof","and","and_eq","asm","atomic_cancel","atomic_commit","atomic_noexcept","auto","bitand","bitor","break","case","catch","class","co_await","co_return","co_yield","compl","concept","const_cast|10","consteval","constexpr","constinit","continue","decltype","default","delete","do","dynamic_cast|10","else","enum","explicit","export","extern","false","final","for","friend","goto","if","import","inline","module","mutable","namespace","new","noexcept","not","not_eq","nullptr","operator","or","or_eq","override","private","protected","public","reflexpr","register","reinterpret_cast|10","requires","return","sizeof","static_assert","static_cast|10","struct","switch","synchronized","template","this","thread_local","throw","transaction_safe","transaction_safe_dynamic","true","try","typedef","typeid","typename","union","using","virtual","volatile","while","xor","xor_eq"], +literal:["NULL","false","nullopt","nullptr","true"],built_in:["_Pragma"], +_type_hints:["any","auto_ptr","barrier","binary_semaphore","bitset","complex","condition_variable","condition_variable_any","counting_semaphore","deque","false_type","future","imaginary","initializer_list","istringstream","jthread","latch","lock_guard","multimap","multiset","mutex","optional","ostringstream","packaged_task","pair","promise","priority_queue","queue","recursive_mutex","recursive_timed_mutex","scoped_lock","set","shared_future","shared_lock","shared_mutex","shared_timed_mutex","shared_ptr","stack","string_view","stringstream","timed_mutex","thread","true_type","tuple","unique_lock","unique_ptr","unordered_map","unordered_multimap","unordered_multiset","unordered_set","variant","vector","weak_ptr","wstring","wstring_view"] +},b={className:"function.dispatch",relevance:0,keywords:{ +_hint:["abort","abs","acos","apply","as_const","asin","atan","atan2","calloc","ceil","cerr","cin","clog","cos","cosh","cout","declval","endl","exchange","exit","exp","fabs","floor","fmod","forward","fprintf","fputs","free","frexp","fscanf","future","invoke","isalnum","isalpha","iscntrl","isdigit","isgraph","islower","isprint","ispunct","isspace","isupper","isxdigit","labs","launder","ldexp","log","log10","make_pair","make_shared","make_shared_for_overwrite","make_tuple","make_unique","malloc","memchr","memcmp","memcpy","memset","modf","move","pow","printf","putchar","puts","realloc","scanf","sin","sinh","snprintf","sprintf","sqrt","sscanf","std","stderr","stdin","stdout","strcat","strchr","strcmp","strcpy","strcspn","strlen","strncat","strncmp","strncpy","strpbrk","strrchr","strspn","strstr","swap","tan","tanh","terminate","to_underlying","tolower","toupper","vfprintf","visit","vprintf","vsprintf"] +}, +begin:n.concat(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!switch)/,/(?!while)/,e.IDENT_RE,n.lookahead(/(<[^<>]+>|)\s*\(/)) +},m=[b,c,s,t,e.C_BLOCK_COMMENT_MODE,l,o],p={variants:[{begin:/=/,end:/;/},{ +begin:/\(/,end:/\)/},{beginKeywords:"new throw return else",end:/;/}], +keywords:u,contains:m.concat([{begin:/\(/,end:/\)/,keywords:u, +contains:m.concat(["self"]),relevance:0}]),relevance:0},_={className:"function", +begin:"("+r+"[\\*&\\s]+)+"+g,returnBegin:!0,end:/[{;=]/,excludeEnd:!0, +keywords:u,illegal:/[^\w\s\*&:<>.]/,contains:[{begin:a,keywords:u,relevance:0},{ +begin:g,returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{ +begin:/:/,endsWithParent:!0,contains:[o,l]},{relevance:0,match:/,/},{ +className:"params",begin:/\(/,end:/\)/,keywords:u,relevance:0, +contains:[t,e.C_BLOCK_COMMENT_MODE,o,l,s,{begin:/\(/,end:/\)/,keywords:u, +relevance:0,contains:["self",t,e.C_BLOCK_COMMENT_MODE,o,l,s]}] +},s,t,e.C_BLOCK_COMMENT_MODE,c]};return{name:"C++", +aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:u,illegal:"",keywords:u,contains:["self",s]},{begin:e.IDENT_RE+"::",keywords:u},{ +match:[/\b(?:enum(?:\s+(?:class|struct))?|class|struct|union)/,/\s+/,/\w+/], +className:{1:"keyword",3:"title.class"}}])}},grmr_csharp:e=>{const n={ +keyword:["abstract","as","base","break","case","catch","class","const","continue","do","else","event","explicit","extern","finally","fixed","for","foreach","goto","if","implicit","in","interface","internal","is","lock","namespace","new","operator","out","override","params","private","protected","public","readonly","record","ref","return","scoped","sealed","sizeof","stackalloc","static","struct","switch","this","throw","try","typeof","unchecked","unsafe","using","virtual","void","volatile","while"].concat(["add","alias","and","ascending","async","await","by","descending","equals","from","get","global","group","init","into","join","let","nameof","not","notnull","on","or","orderby","partial","remove","select","set","unmanaged","value|0","var","when","where","with","yield"]), +built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], +literal:["default","false","null","true"]},t=e.inherit(e.TITLE_MODE,{ +begin:"[a-zA-Z](\\.?\\w)*"}),a={className:"number",variants:[{ +begin:"\\b(0b[01']+)"},{ +begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ +begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" +}],relevance:0},i={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] +},r=e.inherit(i,{illegal:/\n/}),s={className:"subst",begin:/\{/,end:/\}/, +keywords:n},o=e.inherit(s,{illegal:/\n/}),l={className:"string",begin:/\$"/, +end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ +},e.BACKSLASH_ESCAPE,o]},c={className:"string",begin:/\$@"/,end:'"',contains:[{ +begin:/\{\{/},{begin:/\}\}/},{begin:'""'},s]},d=e.inherit(c,{illegal:/\n/, +contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},o]}) +;s.contains=[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.C_BLOCK_COMMENT_MODE], +o.contains=[d,l,r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,a,e.inherit(e.C_BLOCK_COMMENT_MODE,{ +illegal:/\n/})];const g={variants:[c,l,i,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},u={begin:"<",end:">",contains:[{beginKeywords:"in out"},t] +},b=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",m={ +begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], +keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, +contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ +begin:"\x3c!--|--\x3e"},{begin:""}]}] +}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", +end:"$",keywords:{ +keyword:"if else elif endif define undef warning error line region endregion pragma checksum" +}},g,a,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, +illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" +},t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", +relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, +contains:[t,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", +begin:"^\\s*\\[(?=[\\w])",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ +className:"string",begin:/"/,end:/"/}]},{ +beginKeywords:"new return throw await else",relevance:0},{className:"function", +begin:"("+b+"\\s+)+"+e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ +beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", +relevance:0},{begin:e.IDENT_RE+"\\s*(<[^=]+>\\s*)?\\(",returnBegin:!0, +contains:[e.TITLE_MODE,u],relevance:0},{match:/\(\)/},{className:"params", +begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, +contains:[g,a,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},m]}},grmr_css:e=>{ +const n=e.regex,t=ie(e),a=[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE];return{ +name:"CSS",case_insensitive:!0,illegal:/[=|'\$]/,keywords:{ +keyframePosition:"from to"},classNameAliases:{keyframePosition:"selector-tag"}, +contains:[t.BLOCK_COMMENT,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ +},t.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 +},{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 +},t.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ +begin:":("+oe.join("|")+")"},{begin:":(:)?("+le.join("|")+")"}] +},t.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b"},{ +begin:/:/,end:/[;}{]/, +contains:[t.BLOCK_COMMENT,t.HEXCOLOR,t.IMPORTANT,t.CSS_NUMBER_MODE,...a,{ +begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" +},contains:[...a,{className:"string",begin:/[^)]/,endsWithParent:!0, +excludeEnd:!0}]},t.FUNCTION_DISPATCH]},{begin:n.lookahead(/@/),end:"[{;]", +relevance:0,illegal:/:/,contains:[{className:"keyword",begin:/@-?\w[\w]*(-\w+)*/ +},{begin:/\s/,endsWithParent:!0,excludeEnd:!0,relevance:0,keywords:{ +$pattern:/[a-z-]+/,keyword:"and or not only",attribute:se.join(" ")},contains:[{ +begin:/[a-z-]+(?=:)/,className:"attribute"},...a,t.CSS_NUMBER_MODE]}]},{ +className:"selector-tag",begin:"\\b("+re.join("|")+")\\b"}]}},grmr_diff:e=>{ +const n=e.regex;return{name:"Diff",aliases:["patch"],contains:[{ +className:"meta",relevance:10, +match:n.either(/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/,/^\*\*\* +\d+,\d+ +\*\*\*\*$/,/^--- +\d+,\d+ +----$/) +},{className:"comment",variants:[{ +begin:n.either(/Index: /,/^index/,/={3,}/,/^-{3}/,/^\*{3} /,/^\+{3}/,/^diff --git/), +end:/$/},{match:/^\*{15}$/}]},{className:"addition",begin:/^\+/,end:/$/},{ +className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, +end:/$/}]}},grmr_go:e=>{const n={ +keyword:["break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"], +type:["bool","byte","complex64","complex128","error","float32","float64","int8","int16","int32","int64","string","uint8","uint16","uint32","uint64","int","uint","uintptr","rune"], +literal:["true","false","iota","nil"], +built_in:["append","cap","close","complex","copy","imag","len","make","new","panic","print","println","real","recover","delete"] +};return{name:"Go",aliases:["golang"],keywords:n,illegal:"{const n=e.regex;return{name:"GraphQL",aliases:["gql"], +case_insensitive:!0,disableAutodetect:!1,keywords:{ +keyword:["query","mutation","subscription","type","input","schema","directive","interface","union","scalar","fragment","enum","on"], +literal:["true","false","null"]}, +contains:[e.HASH_COMMENT_MODE,e.QUOTE_STRING_MODE,e.NUMBER_MODE,{ +scope:"punctuation",match:/[.]{3}/,relevance:0},{scope:"punctuation", +begin:/[\!\(\)\:\=\[\]\{\|\}]{1}/,relevance:0},{scope:"variable",begin:/\$/, +end:/\W/,excludeEnd:!0,relevance:0},{scope:"meta",match:/@\w+/,excludeEnd:!0},{ +scope:"symbol",begin:n.concat(/[_A-Za-z][_0-9A-Za-z]*/,n.lookahead(/\s*:/)), +relevance:0}],illegal:[/[;<']/,/BEGIN/]}},grmr_ini:e=>{const n=e.regex,t={ +className:"number",relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{ +begin:e.NUMBER_RE}]},a=e.COMMENT();a.variants=[{begin:/;/,end:/$/},{begin:/#/, +end:/$/}];const i={className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{ +begin:/\$\{(.*?)\}/}]},r={className:"literal", +begin:/\bon|off|true|false|yes|no\b/},s={className:"string", +contains:[e.BACKSLASH_ESCAPE],variants:[{begin:"'''",end:"'''",relevance:10},{ +begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"'},{begin:"'",end:"'"}] +},o={begin:/\[/,end:/\]/,contains:[a,r,i,s,t,"self"],relevance:0 +},l=n.either(/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/);return{ +name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, +contains:[a,{className:"section",begin:/\[+/,end:/\]+/},{ +begin:n.concat(l,"(\\s*\\.\\s*",l,")*",n.lookahead(/\s*=\s*[^#\s]/)), +className:"attr",starts:{end:/$/,contains:[a,o,r,i,s,t]}}]}},grmr_java:e=>{ +const n=e.regex,t="[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*",a=t+pe("(?:<"+t+"~~~(?:\\s*,\\s*"+t+"~~~)*>)?",/~~~/g,2),i={ +keyword:["synchronized","abstract","private","var","static","if","const ","for","while","strictfp","finally","protected","import","native","final","void","enum","else","break","transient","catch","instanceof","volatile","case","assert","package","default","public","try","switch","continue","throws","protected","public","private","module","requires","exports","do","sealed","yield","permits"], +literal:["false","true","null"], +type:["char","boolean","long","float","int","byte","short","double"], +built_in:["super","this"]},r={className:"meta",begin:"@"+t,contains:[{ +begin:/\(/,end:/\)/,contains:["self"]}]},s={className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE],endsParent:!0} +;return{name:"Java",aliases:["jsp"],keywords:i,illegal:/<\/|#/, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, +relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ +begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{begin:/"""/,end:/"""/, +className:"string",contains:[e.BACKSLASH_ESCAPE] +},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ +match:[/\b(?:class|interface|enum|extends|implements|new)/,/\s+/,t],className:{ +1:"keyword",3:"title.class"}},{match:/non-sealed/,scope:"keyword"},{ +begin:[n.concat(/(?!else)/,t),/\s+/,t,/\s+/,/=(?!=)/],className:{1:"type", +3:"variable",5:"operator"}},{begin:[/record/,/\s+/,t],className:{1:"keyword", +3:"title.class"},contains:[s,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ +beginKeywords:"new throw return else",relevance:0},{ +begin:["(?:"+a+"\\s+)",e.UNDERSCORE_IDENT_RE,/\s*(?=\()/],className:{ +2:"title.function"},keywords:i,contains:[{className:"params",begin:/\(/, +end:/\)/,keywords:i,relevance:0, +contains:[r,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,me,e.C_BLOCK_COMMENT_MODE] +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},me,r]}},grmr_javascript:Oe, +grmr_json:e=>{const n=["true","false","null"],t={scope:"literal", +beginKeywords:n.join(" ")};return{name:"JSON",keywords:{literal:n},contains:[{ +className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{ +match:/[{}[\],:]/,className:"punctuation",relevance:0 +},e.QUOTE_STRING_MODE,t,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE], +illegal:"\\S"}},grmr_kotlin:e=>{const n={ +keyword:"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual", +built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", +literal:"true false null"},t={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" +},a={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},i={ +className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", +variants:[{begin:'"""',end:'"""(?=[^"])',contains:[i,a]},{begin:"'",end:"'", +illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, +contains:[e.BACKSLASH_ESCAPE,i,a]}]};a.contains.push(r);const s={ +className:"meta", +begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" +},o={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, +end:/\)/,contains:[e.inherit(r,{className:"string"}),"self"]}] +},l=me,c=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),d={ +variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, +contains:[]}]},g=d;return g.variants[1].contains=[d],d.variants[1].contains=[g], +{name:"Kotlin",aliases:["kt","kts"],keywords:n, +contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", +begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,c,{className:"keyword", +begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", +begin:/@\w+/}]}},t,s,o,{className:"function",beginKeywords:"fun",end:"[(]|$", +returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ +begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, +contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin://, +keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, +endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, +endsWithParent:!0,contains:[d,e.C_LINE_COMMENT_MODE,c],relevance:0 +},e.C_LINE_COMMENT_MODE,c,s,o,r,e.C_NUMBER_MODE]},c]},{ +begin:[/class|interface|trait/,/\s+/,e.UNDERSCORE_IDENT_RE],beginScope:{ +3:"title.class"},keywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, +illegal:"extends implements",contains:[{ +beginKeywords:"public protected internal private constructor" +},e.UNDERSCORE_TITLE_MODE,{className:"type",begin://,excludeBegin:!0, +excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,){\s]|$/, +excludeBegin:!0,returnEnd:!0},s,o]},r,{className:"meta",begin:"^#!/usr/bin/env", +end:"$",illegal:"\n"},l]}},grmr_less:e=>{ +const n=ie(e),t=de,a="[\\w-]+",i="("+a+"|@\\{"+a+"\\})",r=[],s=[],o=e=>({ +className:"string",begin:"~?"+e+".*?"+e}),l=(e,n,t)=>({className:e,begin:n, +relevance:t}),c={$pattern:/[a-z-]+/,keyword:"and or not only", +attribute:se.join(" ")},d={begin:"\\(",end:"\\)",contains:s,keywords:c, +relevance:0} +;s.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,o("'"),o('"'),n.CSS_NUMBER_MODE,{ +begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", +excludeEnd:!0} +},n.HEXCOLOR,d,l("variable","@@?"+a,10),l("variable","@\\{"+a+"\\}"),l("built_in","~?`[^`]*?`"),{ +className:"attribute",begin:a+"\\s*:",end:":",returnBegin:!0,excludeEnd:!0 +},n.IMPORTANT,{beginKeywords:"and not"},n.FUNCTION_DISPATCH);const g=s.concat({ +begin:/\{/,end:/\}/,contains:r}),u={beginKeywords:"when",endsWithParent:!0, +contains:[{beginKeywords:"and not"}].concat(s)},b={begin:i+"\\s*:", +returnBegin:!0,end:/[;}]/,relevance:0,contains:[{begin:/-(webkit|moz|ms|o)-/ +},n.CSS_VARIABLE,{className:"attribute",begin:"\\b("+ce.join("|")+")\\b", +end:/(?=:)/,starts:{endsWithParent:!0,illegal:"[<=$]",relevance:0,contains:s}}] +},m={className:"keyword", +begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", +starts:{end:"[;{}]",keywords:c,returnEnd:!0,contains:s,relevance:0}},p={ +className:"variable",variants:[{begin:"@"+a+"\\s*:",relevance:15},{begin:"@"+a +}],starts:{end:"[;}]",returnEnd:!0,contains:g}},_={variants:[{ +begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:i,end:/\{/}],returnBegin:!0, +returnEnd:!0,illegal:"[<='$\"]",relevance:0, +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,u,l("keyword","all\\b"),l("variable","@\\{"+a+"\\}"),{ +begin:"\\b("+re.join("|")+")\\b",className:"selector-tag" +},n.CSS_NUMBER_MODE,l("selector-tag",i,0),l("selector-id","#"+i),l("selector-class","\\."+i,0),l("selector-tag","&",0),n.ATTRIBUTE_SELECTOR_MODE,{ +className:"selector-pseudo",begin:":("+oe.join("|")+")"},{ +className:"selector-pseudo",begin:":(:)?("+le.join("|")+")"},{begin:/\(/, +end:/\)/,relevance:0,contains:g},{begin:"!important"},n.FUNCTION_DISPATCH]},h={ +begin:a+":(:)?"+`(${t.join("|")})`,returnBegin:!0,contains:[_]} +;return r.push(e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,m,p,h,b,_,u,n.FUNCTION_DISPATCH), +{name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:r}}, +grmr_lua:e=>{const n="\\[=*\\[",t="\\]=*\\]",a={begin:n,end:t,contains:["self"] +},i=[e.COMMENT("--(?!"+n+")","$"),e.COMMENT("--"+n,t,{contains:[a],relevance:10 +})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, +literal:"true false nil", +keyword:"and break do else elseif end for goto if in local not or repeat return then until while", +built_in:"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove" +},contains:i.concat([{className:"function",beginKeywords:"function",end:"\\)", +contains:[e.inherit(e.TITLE_MODE,{ +begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", +begin:"\\(",endsWithParent:!0,contains:i}].concat(i) +},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", +begin:n,end:t,contains:[a],relevance:5}])}},grmr_makefile:e=>{const n={ +className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%{ +const n={begin:/<\/?[A-Za-z_]/,end:">",subLanguage:"xml",relevance:0},t={ +variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0},{ +begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, +relevance:2},{ +begin:e.regex.concat(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), +relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ +begin:/\[.*?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{match:/\[(?=\])/ +},{className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, +returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", +excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", +end:"\\]",excludeBegin:!0,excludeEnd:!0}]},a={className:"strong",contains:[], +variants:[{begin:/_{2}(?!\s)/,end:/_{2}/},{begin:/\*{2}(?!\s)/,end:/\*{2}/}] +},i={className:"emphasis",contains:[],variants:[{begin:/\*(?![*\s])/,end:/\*/},{ +begin:/_(?![_\s])/,end:/_/,relevance:0}]},r=e.inherit(a,{contains:[] +}),s=e.inherit(i,{contains:[]});a.contains.push(s),i.contains.push(r) +;let o=[n,t];return[a,i,r,s].forEach((e=>{e.contains=e.contains.concat(o) +})),o=o.concat(a,i),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ +className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:o},{ +begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", +contains:o}]}]},n,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", +end:"\\s+",excludeEnd:!0},a,i,{className:"quote",begin:"^>\\s+",contains:o, +end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ +begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ +begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", +contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ +begin:"^[-\\*]{3,}",end:"$"},t,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ +className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ +className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}},grmr_objectivec:e=>{ +const n=/[a-zA-Z@][a-zA-Z0-9_]*/,t={$pattern:n, +keyword:["@interface","@class","@protocol","@implementation"]};return{ +name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], +keywords:{"variable.language":["this","super"],$pattern:n, +keyword:["while","export","sizeof","typedef","const","struct","for","union","volatile","static","mutable","if","do","return","goto","enum","else","break","extern","asm","case","default","register","explicit","typename","switch","continue","inline","readonly","assign","readwrite","self","@synchronized","id","typeof","nonatomic","IBOutlet","IBAction","strong","weak","copy","in","out","inout","bycopy","byref","oneway","__strong","__weak","__block","__autoreleasing","@private","@protected","@public","@try","@property","@end","@throw","@catch","@finally","@autoreleasepool","@synthesize","@dynamic","@selector","@optional","@required","@encode","@package","@import","@defs","@compatibility_alias","__bridge","__bridge_transfer","__bridge_retained","__bridge_retain","__covariant","__contravariant","__kindof","_Nonnull","_Nullable","_Null_unspecified","__FUNCTION__","__PRETTY_FUNCTION__","__attribute__","getter","setter","retain","unsafe_unretained","nonnull","nullable","null_unspecified","null_resettable","class","instancetype","NS_DESIGNATED_INITIALIZER","NS_UNAVAILABLE","NS_REQUIRES_SUPER","NS_RETURNS_INNER_POINTER","NS_INLINE","NS_AVAILABLE","NS_DEPRECATED","NS_ENUM","NS_OPTIONS","NS_SWIFT_UNAVAILABLE","NS_ASSUME_NONNULL_BEGIN","NS_ASSUME_NONNULL_END","NS_REFINED_FOR_SWIFT","NS_SWIFT_NAME","NS_SWIFT_NOTHROW","NS_DURING","NS_HANDLER","NS_ENDHANDLER","NS_VALUERETURN","NS_VOIDRETURN"], +literal:["false","true","FALSE","TRUE","nil","YES","NO","NULL"], +built_in:["dispatch_once_t","dispatch_queue_t","dispatch_sync","dispatch_async","dispatch_once"], +type:["int","float","char","unsigned","signed","short","long","double","wchar_t","unichar","void","bool","BOOL","id|0","_Bool"] +},illegal:"/,end:/$/,illegal:"\\n" +},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"class", +begin:"("+t.keyword.join("|")+")\\b",end:/(\{|$)/,excludeEnd:!0,keywords:t, +contains:[e.UNDERSCORE_TITLE_MODE]},{begin:"\\."+e.UNDERSCORE_IDENT_RE, +relevance:0}]}},grmr_perl:e=>{const n=e.regex,t=/[dualxmsipngr]{0,12}/,a={ +$pattern:/[\w.]+/, +keyword:"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0" +},i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:a},r={begin:/->\{/, +end:/\}/},s={variants:[{begin:/\$\d/},{ +begin:n.concat(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") +},{begin:/[$%@][^\s\w{]/,relevance:0}] +},o=[e.BACKSLASH_ESCAPE,i,s],l=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],c=(e,a,i="\\1")=>{ +const r="\\1"===i?i:n.concat(i,a) +;return n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,r,/(?:\\.|[^\\\/])*?/,i,t) +},d=(e,a,i)=>n.concat(n.concat("(?:",e,")"),a,/(?:\\.|[^\\\/])*?/,i,t),g=[s,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ +endsWithParent:!0}),r,{className:"string",contains:o,variants:[{ +begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", +end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ +begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", +relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", +contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", +contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ +begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", +begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", +relevance:0},{ +begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", +keywords:"split return print reverse grep",relevance:0, +contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ +begin:c("s|tr|y",n.either(...l,{capture:!0}))},{begin:c("s|tr|y","\\(","\\)")},{ +begin:c("s|tr|y","\\[","\\]")},{begin:c("s|tr|y","\\{","\\}")}],relevance:2},{ +className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ +begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",n.either(...l,{capture:!0 +}),/\1/)},{begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ +begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", +end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ +begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", +subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] +}];return i.contains=g,r.contains=g,{name:"Perl",aliases:["pl","pm"],keywords:a, +contains:g}},grmr_php:e=>{ +const n=e.regex,t=/(?![A-Za-z0-9])(?![$])/,a=n.concat(/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/,t),i=n.concat(/(\\?[A-Z][a-z0-9_\x7f-\xff]+|\\?[A-Z]+(?=[A-Z][a-z0-9_\x7f-\xff])){1,}/,t),r={ +scope:"variable",match:"\\$+"+a},s={scope:"subst",variants:[{begin:/\$\w+/},{ +begin:/\{\$/,end:/\}/}]},o=e.inherit(e.APOS_STRING_MODE,{illegal:null +}),l="[ \t\n]",c={scope:"string",variants:[e.inherit(e.QUOTE_STRING_MODE,{ +illegal:null,contains:e.QUOTE_STRING_MODE.contains.concat(s)}),o,{ +begin:/<<<[ \t]*(?:(\w+)|"(\w+)")\n/,end:/[ \t]*(\w+)\b/, +contains:e.QUOTE_STRING_MODE.contains.concat(s),"on:begin":(e,n)=>{ +n.data._beginMatch=e[1]||e[2]},"on:end":(e,n)=>{ +n.data._beginMatch!==e[1]&&n.ignoreMatch()}},e.END_SAME_AS_BEGIN({ +begin:/<<<[ \t]*'(\w+)'\n/,end:/[ \t]*(\w+)\b/})]},d={scope:"number",variants:[{ +begin:"\\b0[bB][01]+(?:_[01]+)*\\b"},{begin:"\\b0[oO][0-7]+(?:_[0-7]+)*\\b"},{ +begin:"\\b0[xX][\\da-fA-F]+(?:_[\\da-fA-F]+)*\\b"},{ +begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:[eE][+-]?\\d+)?" +}],relevance:0 +},g=["false","null","true"],u=["__CLASS__","__DIR__","__FILE__","__FUNCTION__","__COMPILER_HALT_OFFSET__","__LINE__","__METHOD__","__NAMESPACE__","__TRAIT__","die","echo","exit","include","include_once","print","require","require_once","array","abstract","and","as","binary","bool","boolean","break","callable","case","catch","class","clone","const","continue","declare","default","do","double","else","elseif","empty","enddeclare","endfor","endforeach","endif","endswitch","endwhile","enum","eval","extends","final","finally","float","for","foreach","from","global","goto","if","implements","instanceof","insteadof","int","integer","interface","isset","iterable","list","match|0","mixed","new","never","object","or","private","protected","public","readonly","real","return","string","switch","throw","trait","try","unset","use","var","void","while","xor","yield"],b=["Error|0","AppendIterator","ArgumentCountError","ArithmeticError","ArrayIterator","ArrayObject","AssertionError","BadFunctionCallException","BadMethodCallException","CachingIterator","CallbackFilterIterator","CompileError","Countable","DirectoryIterator","DivisionByZeroError","DomainException","EmptyIterator","ErrorException","Exception","FilesystemIterator","FilterIterator","GlobIterator","InfiniteIterator","InvalidArgumentException","IteratorIterator","LengthException","LimitIterator","LogicException","MultipleIterator","NoRewindIterator","OutOfBoundsException","OutOfRangeException","OuterIterator","OverflowException","ParentIterator","ParseError","RangeException","RecursiveArrayIterator","RecursiveCachingIterator","RecursiveCallbackFilterIterator","RecursiveDirectoryIterator","RecursiveFilterIterator","RecursiveIterator","RecursiveIteratorIterator","RecursiveRegexIterator","RecursiveTreeIterator","RegexIterator","RuntimeException","SeekableIterator","SplDoublyLinkedList","SplFileInfo","SplFileObject","SplFixedArray","SplHeap","SplMaxHeap","SplMinHeap","SplObjectStorage","SplObserver","SplPriorityQueue","SplQueue","SplStack","SplSubject","SplTempFileObject","TypeError","UnderflowException","UnexpectedValueException","UnhandledMatchError","ArrayAccess","BackedEnum","Closure","Fiber","Generator","Iterator","IteratorAggregate","Serializable","Stringable","Throwable","Traversable","UnitEnum","WeakReference","WeakMap","Directory","__PHP_Incomplete_Class","parent","php_user_filter","self","static","stdClass"],m={ +keyword:u,literal:(e=>{const n=[];return e.forEach((e=>{ +n.push(e),e.toLowerCase()===e?n.push(e.toUpperCase()):n.push(e.toLowerCase()) +})),n})(g),built_in:b},p=e=>e.map((e=>e.replace(/\|\d+$/,""))),_={variants:[{ +match:[/new/,n.concat(l,"+"),n.concat("(?!",p(b).join("\\b|"),"\\b)"),i],scope:{ +1:"keyword",4:"title.class"}}]},h=n.concat(a,"\\b(?!\\()"),f={variants:[{ +match:[n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{2:"variable.constant" +}},{match:[/::/,/class/],scope:{2:"variable.language"}},{ +match:[i,n.concat(/::/,n.lookahead(/(?!class\b)/)),h],scope:{1:"title.class", +3:"variable.constant"}},{match:[i,n.concat("::",n.lookahead(/(?!class\b)/))], +scope:{1:"title.class"}},{match:[i,/::/,/class/],scope:{1:"title.class", +3:"variable.language"}}]},E={scope:"attr", +match:n.concat(a,n.lookahead(":"),n.lookahead(/(?!::)/))},y={relevance:0, +begin:/\(/,end:/\)/,keywords:m,contains:[E,r,f,e.C_BLOCK_COMMENT_MODE,c,d,_] +},N={relevance:0, +match:[/\b/,n.concat("(?!fn\\b|function\\b|",p(u).join("\\b|"),"|",p(b).join("\\b|"),"\\b)"),a,n.concat(l,"*"),n.lookahead(/(?=\()/)], +scope:{3:"title.function.invoke"},contains:[y]};y.contains.push(N) +;const w=[E,f,e.C_BLOCK_COMMENT_MODE,c,d,_];return{case_insensitive:!1, +keywords:m,contains:[{begin:n.concat(/#\[\s*/,i),beginScope:"meta",end:/]/, +endScope:"meta",keywords:{literal:g,keyword:["new","array"]},contains:[{ +begin:/\[/,end:/]/,keywords:{literal:g,keyword:["new","array"]}, +contains:["self",...w]},...w,{scope:"meta",match:i}] +},e.HASH_COMMENT_MODE,e.COMMENT("//","$"),e.COMMENT("/\\*","\\*/",{contains:[{ +scope:"doctag",match:"@[A-Za-z]+"}]}),{match:/__halt_compiler\(\);/, +keywords:"__halt_compiler",starts:{scope:"comment",end:e.MATCH_NOTHING_RE, +contains:[{match:/\?>/,scope:"meta",endsParent:!0}]}},{scope:"meta",variants:[{ +begin:/<\?php/,relevance:10},{begin:/<\?=/},{begin:/<\?/,relevance:.1},{ +begin:/\?>/}]},{scope:"variable.language",match:/\$this\b/},r,N,f,{ +match:[/const/,/\s/,a],scope:{1:"keyword",3:"variable.constant"}},_,{ +scope:"function",relevance:0,beginKeywords:"fn function",end:/[;{]/, +excludeEnd:!0,illegal:"[$%\\[]",contains:[{beginKeywords:"use" +},e.UNDERSCORE_TITLE_MODE,{begin:"=>",endsParent:!0},{scope:"params", +begin:"\\(",end:"\\)",excludeBegin:!0,excludeEnd:!0,keywords:m, +contains:["self",r,f,e.C_BLOCK_COMMENT_MODE,c,d]}]},{scope:"class",variants:[{ +beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", +illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ +beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ +beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, +contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{scope:"title.class"})]},{ +beginKeywords:"use",relevance:0,end:";",contains:[{ +match:/\b(as|const|function)\b/,scope:"keyword"},e.UNDERSCORE_TITLE_MODE]},c,d]} +},grmr_php_template:e=>({name:"PHP template",subLanguage:"xml",contains:[{ +begin:/<\?(php|=)?/,end:/\?>/,subLanguage:"php",contains:[{begin:"/\\*", +end:"\\*/",skip:!0},{begin:'b"',end:'"',skip:!0},{begin:"b'",end:"'",skip:!0 +},e.inherit(e.APOS_STRING_MODE,{illegal:null,className:null,contains:null, +skip:!0}),e.inherit(e.QUOTE_STRING_MODE,{illegal:null,className:null, +contains:null,skip:!0})]}]}),grmr_plaintext:e=>({name:"Plain text", +aliases:["text","txt"],disableAutodetect:!0}),grmr_python:e=>{ +const n=e.regex,t=/[\p{XID_Start}_]\p{XID_Continue}*/u,a=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={ +$pattern:/[A-Za-z]\w+|__\w+__/,keyword:a, +built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"], +literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], +type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] +},r={className:"meta",begin:/^(>>>|\.\.\.) /},s={className:"subst",begin:/\{/, +end:/\}/,keywords:i,illegal:/#/},o={begin:/\{\{/,relevance:0},l={ +className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, +contains:[e.BACKSLASH_ESCAPE,r],relevance:10},{ +begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, +contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, +end:/"""/,contains:[e.BACKSLASH_ESCAPE,r,o,s]},{begin:/([uU]|[rR])'/,end:/'/, +relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ +begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, +end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, +contains:[e.BACKSLASH_ESCAPE,o,s]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, +contains:[e.BACKSLASH_ESCAPE,o,s]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] +},c="[0-9](_?[0-9])*",d=`(\\b(${c}))?\\.(${c})|\\b(${c})\\.`,g="\\b|"+a.join("|"),u={ +className:"number",relevance:0,variants:[{ +begin:`(\\b(${c})|(${d}))[eE][+-]?(${c})[jJ]?(?=${g})`},{begin:`(${d})[jJ]?`},{ +begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${g})`},{ +begin:`\\b0[bB](_?[01])+[lL]?(?=${g})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${g})` +},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${g})`},{begin:`\\b(${c})[jJ](?=${g})` +}]},b={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i, +contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={ +className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/, +end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i, +contains:["self",r,u,l,e.HASH_COMMENT_MODE]}]};return s.contains=[l,u,r],{ +name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i, +illegal:/(<\/|\?)|=>/,contains:[r,u,{begin:/\bself\b/},{beginKeywords:"if", +relevance:0},l,b,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,t],scope:{ +1:"keyword",3:"title.function"},contains:[m]},{variants:[{ +match:[/\bclass/,/\s+/,t,/\s*/,/\(\s*/,t,/\s*\)/]},{match:[/\bclass/,/\s+/,t]}], +scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{ +className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[u,m,l]}]}}, +grmr_python_repl:e=>({aliases:["pycon"],contains:[{className:"meta.prompt", +starts:{end:/ |$/,starts:{end:"$",subLanguage:"python"}},variants:[{ +begin:/^>>>(?=[ ]|$)/},{begin:/^\.\.\.(?=[ ]|$)/}]}]}),grmr_r:e=>{ +const n=e.regex,t=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/,a=n.either(/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/,/0[xX][0-9a-fA-F]+(?:[pP][+-]?\d+)?[Li]?/,/(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[Li]?/),i=/[=!<>:]=|\|\||&&|:::?|<-|<<-|->>|->|\|>|[-+*\/?!$&|:<=>@^~]|\*\*/,r=n.either(/[()]/,/[{}]/,/\[\[/,/[[\]]/,/\\/,/,/) +;return{name:"R",keywords:{$pattern:t, +keyword:"function if in break next repeat else for while", +literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", +built_in:"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm" +},contains:[e.COMMENT(/#'/,/$/,{contains:[{scope:"doctag",match:/@examples/, +starts:{end:n.lookahead(n.either(/\n^#'\s*(?=@[a-zA-Z]+)/,/\n^(?!#')/)), +endsParent:!0}},{scope:"doctag",begin:"@param",end:/$/,contains:[{ +scope:"variable",variants:[{match:t},{match:/`(?:\\.|[^`\\])+`/}],endsParent:!0 +}]},{scope:"doctag",match:/@[a-zA-Z]+/},{scope:"keyword",match:/\\[a-zA-Z]+/}] +}),e.HASH_COMMENT_MODE,{scope:"string",contains:[e.BACKSLASH_ESCAPE], +variants:[e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ +}),e.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', +relevance:0},{begin:"'",end:"'",relevance:0}]},{relevance:0,variants:[{scope:{ +1:"operator",2:"number"},match:[i,a]},{scope:{1:"operator",2:"number"}, +match:[/%[^%]*%/,a]},{scope:{1:"punctuation",2:"number"},match:[r,a]},{scope:{ +2:"number"},match:[/[^a-zA-Z0-9._]|^/,a]}]},{scope:{3:"operator"}, +match:[t,/\s+/,/<-/,/\s+/]},{scope:"operator",relevance:0,variants:[{match:i},{ +match:/%[^%]*%/}]},{scope:"punctuation",relevance:0,match:r},{begin:"`",end:"`", +contains:[{begin:/\\./}]}]}},grmr_ruby:e=>{ +const n=e.regex,t="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",a=n.either(/\b([A-Z]+[a-z0-9]+)+/,/\b([A-Z]+[a-z0-9]+)+[A-Z]+/),i=n.concat(a,/(::\w+)*/),r={ +"variable.constant":["__FILE__","__LINE__","__ENCODING__"], +"variable.language":["self","super"], +keyword:["alias","and","begin","BEGIN","break","case","class","defined","do","else","elsif","end","END","ensure","for","if","in","module","next","not","or","redo","require","rescue","retry","return","then","undef","unless","until","when","while","yield","include","extend","prepend","public","private","protected","raise","throw"], +built_in:["proc","lambda","attr_accessor","attr_reader","attr_writer","define_method","private_constant","module_function"], +literal:["true","false","nil"]},s={className:"doctag",begin:"@[A-Za-z]+"},o={ +begin:"#<",end:">"},l=[e.COMMENT("#","$",{contains:[s] +}),e.COMMENT("^=begin","^=end",{contains:[s],relevance:10 +}),e.COMMENT("^__END__",e.MATCH_NOTHING_RE)],c={className:"subst",begin:/#\{/, +end:/\}/,keywords:r},d={className:"string",contains:[e.BACKSLASH_ESCAPE,c], +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{ +begin:/%[qQwWx]?\(/,end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{ +begin:/%[qQwWx]?\{/,end:/\}/},{begin:/%[qQwWx]?/},{begin:/%[qQwWx]?\//, +end:/\//},{begin:/%[qQwWx]?%/,end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{ +begin:/%[qQwWx]?\|/,end:/\|/},{begin:/\B\?(\\\d{1,3})/},{ +begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ +begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ +begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ +begin:n.concat(/<<[-~]?'?/,n.lookahead(/(\w+)(?=\W)[^\n]*\n(?:[^\n]*\n)*?\s*\1\b/)), +contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, +contains:[e.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",u={className:"number", +relevance:0,variants:[{ +begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ +begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" +},{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ +begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ +begin:"\\b0(_?[0-7])+r?i?\\b"}]},b={variants:[{match:/\(\)/},{ +className:"params",begin:/\(/,end:/(?=\))/,excludeBegin:!0,endsParent:!0, +keywords:r}]},m=[d,{variants:[{match:[/class\s+/,i,/\s+<\s+/,i]},{ +match:[/\b(class|module)\s+/,i]}],scope:{2:"title.class", +4:"title.class.inherited"},keywords:r},{match:[/(include|extend)\s+/,i],scope:{ +2:"title.class"},keywords:r},{relevance:0,match:[i,/\.new[. (]/],scope:{ +1:"title.class"}},{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/, +className:"variable.constant"},{relevance:0,match:a,scope:"title.class"},{ +match:[/def/,/\s+/,t],scope:{1:"keyword",3:"title.function"},contains:[b]},{ +begin:e.IDENT_RE+"::"},{className:"symbol", +begin:e.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", +begin:":(?!\\s)",contains:[d,{begin:t}],relevance:0},u,{className:"variable", +begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ +className:"params",begin:/\|/,end:/\|/,excludeBegin:!0,excludeEnd:!0, +relevance:0,keywords:r},{begin:"("+e.RE_STARTERS_RE+"|unless)\\s*", +keywords:"unless",contains:[{className:"regexp",contains:[e.BACKSLASH_ESCAPE,c], +illegal:/\n/,variants:[{begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{ +begin:"%r\\(",end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[", +end:"\\][a-z]*"}]}].concat(o,l),relevance:0}].concat(o,l) +;c.contains=m,b.contains=m;const p=[{begin:/^\s*=>/,starts:{end:"$",contains:m} +},{className:"meta.prompt", +begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+[>*]|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", +starts:{end:"$",keywords:r,contains:m}}];return l.unshift(o),{name:"Ruby", +aliases:["rb","gemspec","podspec","thor","irb"],keywords:r,illegal:/\/\*/, +contains:[e.SHEBANG({binary:"ruby"})].concat(p).concat(l).concat(m)}}, +grmr_rust:e=>{const n=e.regex,t={className:"title.function.invoke",relevance:0, +begin:n.concat(/\b/,/(?!let|for|while|if|else|match\b)/,e.IDENT_RE,n.lookahead(/\s*\(/)) +},a="([ui](8|16|32|64|128|size)|f(32|64))?",i=["drop ","Copy","Send","Sized","Sync","Drop","Fn","FnMut","FnOnce","ToOwned","Clone","Debug","PartialEq","PartialOrd","Eq","Ord","AsRef","AsMut","Into","From","Default","Iterator","Extend","IntoIterator","DoubleEndedIterator","ExactSizeIterator","SliceConcatExt","ToString","assert!","assert_eq!","bitflags!","bytes!","cfg!","col!","concat!","concat_idents!","debug_assert!","debug_assert_eq!","env!","eprintln!","panic!","file!","format!","format_args!","include_bytes!","include_str!","line!","local_data_key!","module_path!","option_env!","print!","println!","select!","stringify!","try!","unimplemented!","unreachable!","vec!","write!","writeln!","macro_rules!","assert_ne!","debug_assert_ne!"],r=["i8","i16","i32","i64","i128","isize","u8","u16","u32","u64","u128","usize","f32","f64","str","char","bool","Box","Option","Result","String","Vec"] +;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?",type:r, +keyword:["abstract","as","async","await","become","box","break","const","continue","crate","do","dyn","else","enum","extern","false","final","fn","for","if","impl","in","let","loop","macro","match","mod","move","mut","override","priv","pub","ref","return","self","Self","static","struct","super","trait","true","try","type","typeof","unsafe","unsized","use","virtual","where","while","yield"], +literal:["true","false","Some","None","Ok","Err"],built_in:i},illegal:""},t]}}, +grmr_scss:e=>{const n=ie(e),t=le,a=oe,i="@[a-z-]+",r={className:"variable", +begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b",relevance:0};return{name:"SCSS", +case_insensitive:!0,illegal:"[=/|']", +contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,n.CSS_NUMBER_MODE,{ +className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ +className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 +},n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", +begin:"\\b("+re.join("|")+")\\b",relevance:0},{className:"selector-pseudo", +begin:":("+a.join("|")+")"},{className:"selector-pseudo", +begin:":(:)?("+t.join("|")+")"},r,{begin:/\(/,end:/\)/, +contains:[n.CSS_NUMBER_MODE]},n.CSS_VARIABLE,{className:"attribute", +begin:"\\b("+ce.join("|")+")\\b"},{ +begin:"\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\b" +},{begin:/:/,end:/[;}{]/,relevance:0, +contains:[n.BLOCK_COMMENT,r,n.HEXCOLOR,n.CSS_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.IMPORTANT,n.FUNCTION_DISPATCH] +},{begin:"@(page|font-face)",keywords:{$pattern:i,keyword:"@page @font-face"}},{ +begin:"@",end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, +keyword:"and or not only",attribute:se.join(" ")},contains:[{begin:i, +className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" +},r,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,n.HEXCOLOR,n.CSS_NUMBER_MODE] +},n.FUNCTION_DISPATCH]}},grmr_shell:e=>({name:"Shell Session", +aliases:["console","shellsession"],contains:[{className:"meta.prompt", +begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,starts:{end:/[^\\](?=\s*$)/, +subLanguage:"bash"}}]}),grmr_sql:e=>{ +const n=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],i=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],r=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=r,l=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!r.includes(e))),c={ +begin:n.concat(/\b/,n.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}} +;return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{ +$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:n,when:t}={})=>{const a=t +;return n=n||[],e.map((e=>e.match(/\|\d+$/)||n.includes(e)?e:a(e)?e+"|0":e)) +})(l,{when:e=>e.length<3}),literal:a,type:i, +built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"] +},contains:[{begin:n.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/, +keyword:l.concat(s),literal:a,type:i}},{className:"type", +begin:n.either("double precision","large object","with timezone","without timezone") +},c,{className:"variable",begin:/@[a-z0-9][a-z0-9_]*/},{className:"string", +variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/, +contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{ +className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/, +relevance:0}]}},grmr_swift:e=>{const n={match:/\s+/,relevance:0 +},t=e.COMMENT("/\\*","\\*/",{contains:["self"]}),a=[e.C_LINE_COMMENT_MODE,t],i={ +match:[/\./,m(...xe,...Me)],className:{2:"keyword"}},r={match:b(/\./,m(...Ae)), +relevance:0},s=Ae.filter((e=>"string"==typeof e)).concat(["_|0"]),o={variants:[{ +className:"keyword", +match:m(...Ae.filter((e=>"string"!=typeof e)).concat(Se).map(ke),...Me)}]},l={ +$pattern:m(/\b\w+/,/#\w+/),keyword:s.concat(Re),literal:Ce},c=[i,r,o],g=[{ +match:b(/\./,m(...De)),relevance:0},{className:"built_in", +match:b(/\b/,m(...De),/(?=\()/)}],u={match:/->/,relevance:0},p=[u,{ +className:"operator",relevance:0,variants:[{match:Be},{match:`\\.(\\.|${Le})+`}] +}],_="([0-9]_*)+",h="([0-9a-fA-F]_*)+",f={className:"number",relevance:0, +variants:[{match:`\\b(${_})(\\.(${_}))?([eE][+-]?(${_}))?\\b`},{ +match:`\\b0x(${h})(\\.(${h}))?([pP][+-]?(${_}))?\\b`},{match:/\b0o([0-7]_*)+\b/ +},{match:/\b0b([01]_*)+\b/}]},E=(e="")=>({className:"subst",variants:[{ +match:b(/\\/,e,/[0\\tnr"']/)},{match:b(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}] +}),y=(e="")=>({className:"subst",match:b(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/) +}),N=(e="")=>({className:"subst",label:"interpol",begin:b(/\\/,e,/\(/),end:/\)/ +}),w=(e="")=>({begin:b(e,/"""/),end:b(/"""/,e),contains:[E(e),y(e),N(e)] +}),v=(e="")=>({begin:b(e,/"/),end:b(/"/,e),contains:[E(e),N(e)]}),O={ +className:"string", +variants:[w(),w("#"),w("##"),w("###"),v(),v("#"),v("##"),v("###")] +},k=[e.BACKSLASH_ESCAPE,{begin:/\[/,end:/\]/,relevance:0, +contains:[e.BACKSLASH_ESCAPE]}],x={begin:/\/[^\s](?=[^/\n]*\/)/,end:/\//, +contains:k},M=e=>{const n=b(e,/\//),t=b(/\//,e);return{begin:n,end:t, +contains:[...k,{scope:"comment",begin:`#(?!.*${t})`,end:/$/}]}},S={ +scope:"regexp",variants:[M("###"),M("##"),M("#"),x]},A={match:b(/`/,Fe,/`/) +},C=[A,{className:"variable",match:/\$\d+/},{className:"variable", +match:`\\$${ze}+`}],T=[{match:/(@|#(un)?)available/,scope:"keyword",starts:{ +contains:[{begin:/\(/,end:/\)/,keywords:Pe,contains:[...p,f,O]}]}},{ +scope:"keyword",match:b(/@/,m(...je))},{scope:"meta",match:b(/@/,Fe)}],R={ +match:d(/\b[A-Z]/),relevance:0,contains:[{className:"type", +match:b(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,ze,"+") +},{className:"type",match:Ue,relevance:0},{match:/[?!]+/,relevance:0},{ +match:/\.\.\./,relevance:0},{match:b(/\s+&\s+/,d(Ue)),relevance:0}]},D={ +begin://,keywords:l,contains:[...a,...c,...T,u,R]};R.contains.push(D) +;const I={begin:/\(/,end:/\)/,relevance:0,keywords:l,contains:["self",{ +match:b(Fe,/\s*:/),keywords:"_|0",relevance:0 +},...a,S,...c,...g,...p,f,O,...C,...T,R]},L={begin://, +keywords:"repeat each",contains:[...a,R]},B={begin:/\(/,end:/\)/,keywords:l, +contains:[{begin:m(d(b(Fe,/\s*:/)),d(b(Fe,/\s+/,Fe,/\s*:/))),end:/:/, +relevance:0,contains:[{className:"keyword",match:/\b_\b/},{className:"params", +match:Fe}]},...a,...c,...p,f,O,...T,R,I],endsParent:!0,illegal:/["']/},$={ +match:[/(func|macro)/,/\s+/,m(A.match,Fe,Be)],className:{1:"keyword", +3:"title.function"},contains:[L,B,n],illegal:[/\[/,/%/]},z={ +match:[/\b(?:subscript|init[?!]?)/,/\s*(?=[<(])/],className:{1:"keyword"}, +contains:[L,B,n],illegal:/\[|%/},F={match:[/operator/,/\s+/,Be],className:{ +1:"keyword",3:"title"}},U={begin:[/precedencegroup/,/\s+/,Ue],className:{ +1:"keyword",3:"title"},contains:[R],keywords:[...Te,...Ce],end:/}/} +;for(const e of O.variants){const n=e.contains.find((e=>"interpol"===e.label)) +;n.keywords=l;const t=[...c,...g,...p,f,O,...C];n.contains=[...t,{begin:/\(/, +end:/\)/,contains:["self",...t]}]}return{name:"Swift",keywords:l, +contains:[...a,$,z,{beginKeywords:"struct protocol class extension enum actor", +end:"\\{",excludeEnd:!0,keywords:l,contains:[e.inherit(e.TITLE_MODE,{ +className:"title.class",begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...c] +},F,U,{beginKeywords:"import",end:/$/,contains:[...a],relevance:0 +},S,...c,...g,...p,f,O,...C,...T,R,I]}},grmr_typescript:e=>{ +const n=Oe(e),t=_e,a=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],i={ +beginKeywords:"namespace",end:/\{/,excludeEnd:!0, +contains:[n.exports.CLASS_REFERENCE]},r={beginKeywords:"interface",end:/\{/, +excludeEnd:!0,keywords:{keyword:"interface extends",built_in:a}, +contains:[n.exports.CLASS_REFERENCE]},s={$pattern:_e, +keyword:he.concat(["type","namespace","interface","public","private","protected","implements","declare","abstract","readonly","enum","override"]), +literal:fe,built_in:ve.concat(a),"variable.language":we},o={className:"meta", +begin:"@"+t},l=(e,n,t)=>{const a=e.contains.findIndex((e=>e.label===n)) +;if(-1===a)throw Error("can not find mode to replace");e.contains.splice(a,1,t)} +;return Object.assign(n.keywords,s), +n.exports.PARAMS_CONTAINS.push(o),n.contains=n.contains.concat([o,i,r]), +l(n,"shebang",e.SHEBANG()),l(n,"use_strict",{className:"meta",relevance:10, +begin:/^\s*['"]use strict['"]/ +}),n.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(n,{ +name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),n},grmr_vbnet:e=>{ +const n=e.regex,t=/\d{1,2}\/\d{1,2}\/\d{4}/,a=/\d{4}-\d{1,2}-\d{1,2}/,i=/(\d|1[012])(:\d+){0,2} *(AM|PM)/,r=/\d{1,2}(:\d{1,2}){1,2}/,s={ +className:"literal",variants:[{begin:n.concat(/# */,n.either(a,t),/ *#/)},{ +begin:n.concat(/# */,r,/ *#/)},{begin:n.concat(/# */,i,/ *#/)},{ +begin:n.concat(/# */,n.either(a,t),/ +/,n.either(i,r),/ *#/)}] +},o=e.COMMENT(/'''/,/$/,{contains:[{className:"doctag",begin:/<\/?/,end:/>/}] +}),l=e.COMMENT(null,/$/,{variants:[{begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]}) +;return{name:"Visual Basic .NET",aliases:["vb"],case_insensitive:!0, +classNameAliases:{label:"symbol"},keywords:{ +keyword:"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield", +built_in:"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort", +type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", +literal:"true false nothing"}, +illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ +className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, +end:/"/,illegal:/\n/,contains:[{begin:/""/}]},s,{className:"number",relevance:0, +variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ +},{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ +begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ +className:"label",begin:/^\w+:/},o,l,{className:"meta", +begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, +end:/$/,keywords:{ +keyword:"const disable else elseif enable end externalsource if region then"}, +contains:[l]}]}},grmr_wasm:e=>{e.regex;const n=e.COMMENT(/\(;/,/;\)/) +;return n.contains.push("self"),{name:"WebAssembly",keywords:{$pattern:/[\w.]+/, +keyword:["anyfunc","block","br","br_if","br_table","call","call_indirect","data","drop","elem","else","end","export","func","global.get","global.set","local.get","local.set","local.tee","get_global","get_local","global","if","import","local","loop","memory","memory.grow","memory.size","module","mut","nop","offset","param","result","return","select","set_global","set_local","start","table","tee_local","then","type","unreachable"] +},contains:[e.COMMENT(/;;/,/$/),n,{match:[/(?:offset|align)/,/\s*/,/=/], +className:{1:"keyword",3:"operator"}},{className:"variable",begin:/\$[\w_]+/},{ +match:/(\((?!;)|\))+/,className:"punctuation",relevance:0},{ +begin:[/(?:func|call|call_indirect)/,/\s+/,/\$[^\s)]+/],className:{1:"keyword", +3:"title.function"}},e.QUOTE_STRING_MODE,{match:/(i32|i64|f32|f64)(?!\.)/, +className:"type"},{className:"keyword", +match:/\b(f32|f64|i32|i64)(?:\.(?:abs|add|and|ceil|clz|const|convert_[su]\/i(?:32|64)|copysign|ctz|demote\/f64|div(?:_[su])?|eqz?|extend_[su]\/i32|floor|ge(?:_[su])?|gt(?:_[su])?|le(?:_[su])?|load(?:(?:8|16|32)_[su])?|lt(?:_[su])?|max|min|mul|nearest|neg?|or|popcnt|promote\/f32|reinterpret\/[fi](?:32|64)|rem_[su]|rot[lr]|shl|shr_[su]|store(?:8|16|32)?|sqrt|sub|trunc(?:_[su]\/f(?:32|64))?|wrap\/i64|xor))\b/ +},{className:"number",relevance:0, +match:/[+-]?\b(?:\d(?:_?\d)*(?:\.\d(?:_?\d)*)?(?:[eE][+-]?\d(?:_?\d)*)?|0x[\da-fA-F](?:_?[\da-fA-F])*(?:\.[\da-fA-F](?:_?[\da-fA-D])*)?(?:[pP][+-]?\d(?:_?\d)*)?)\b|\binf\b|\bnan(?::0x[\da-fA-F](?:_?[\da-fA-D])*)?\b/ +}]}},grmr_xml:e=>{ +const n=e.regex,t=n.concat(/[\p{L}_]/u,n.optional(/[\p{L}0-9_.-]*:/u),/[\p{L}0-9_.-]*/u),a={ +className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},i={begin:/\s/, +contains:[{className:"keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] +},r=e.inherit(i,{begin:/\(/,end:/\)/}),s=e.inherit(e.APOS_STRING_MODE,{ +className:"string"}),o=e.inherit(e.QUOTE_STRING_MODE,{className:"string"}),l={ +endsWithParent:!0,illegal:/`]+/}]}]}]};return{ +name:"HTML, XML", +aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], +case_insensitive:!0,unicodeRegex:!0,contains:[{className:"meta",begin://,relevance:10,contains:[i,o,s,r,{begin:/\[/,end:/\]/,contains:[{ +className:"meta",begin://,contains:[i,r,o,s]}]}] +},e.COMMENT(//,{relevance:10}),{begin://, +relevance:10},a,{className:"meta",end:/\?>/,variants:[{begin:/<\?xml/, +relevance:10,contains:[o]},{begin:/<\?[a-z][a-z0-9]+/}]},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"style"},contains:[l],starts:{ +end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", +begin:/)/,end:/>/,keywords:{name:"script"},contains:[l],starts:{ +end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ +className:"tag",begin:/<>|<\/>/},{className:"tag", +begin:n.concat(//,/>/,/\s/)))), +end:/\/?>/,contains:[{className:"name",begin:t,relevance:0,starts:l}]},{ +className:"tag",begin:n.concat(/<\//,n.lookahead(n.concat(t,/>/))),contains:[{ +className:"name",begin:t,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]} +},grmr_yaml:e=>{ +const n="true false yes no null",t="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={ +className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ +},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", +variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(a,{ +variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),r={ +end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},s={begin:/\{/, +end:/\}/,contains:[r],illegal:"\\n",relevance:0},o={begin:"\\[",end:"\\]", +contains:[r],illegal:"\\n",relevance:0},l=[{className:"attr",variants:[{ +begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ +begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", +relevance:10},{className:"string", +begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ +begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, +relevance:0},{className:"type",begin:"!\\w+!"+t},{className:"type", +begin:"!<"+t+">"},{className:"type",begin:"!"+t},{className:"type",begin:"!!"+t +},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", +begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", +relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ +className:"number", +begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b" +},{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},s,o,a],c=[...l] +;return c.pop(),c.push(i),r.contains=c,{name:"YAML",case_insensitive:!0, +aliases:["yml"],contains:l}}});const He=ae;for(const e of Object.keys(Ke)){ +const n=e.replace("grmr_","").replace("_","-");He.registerLanguage(n,Ke[e])} +return He}() +;"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs); \ No newline at end of file diff --git a/packages/coding-agent/src/core/export-html/vendor/marked.min.js b/packages/coding-agent/src/core/export-html/vendor/marked.min.js new file mode 100644 index 00000000..79394fd8 --- /dev/null +++ b/packages/coding-agent/src/core/export-html/vendor/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v15.0.4 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s={exec:()=>null};function r(e,t=""){let n="string"==typeof e?e:e.source;const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(i.caret,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}const i={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:e=>new RegExp(`^( {0,3}${e})((?:[\t ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),hrRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}#`),htmlBeginRegex:e=>new RegExp(`^ {0,${Math.min(3,e-1)}}<(?:[a-z].*>|!--)`,"i")},l=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,o=/(?:[*+-]|\d{1,9}[.)])/,a=r(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,o).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),c=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,h=/(?!\s*\])(?:\\.|[^\[\]\\])+/,p=r(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",h).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),u=r(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,o).getRegex(),g="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",k=/|$))/,f=r("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$))","i").replace("comment",k).replace("tag",g).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),d=r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),x={blockquote:r(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",d).getRegex(),code:/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,def:p,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:l,html:f,lheading:a,list:u,newline:/^(?:[ \t]*(?:\n|$))+/,paragraph:d,table:s,text:/^[^\n]+/},b=r("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3}\t)[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex(),w={...x,table:b,paragraph:r(c).replace("hr",l).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",b).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",g).getRegex()},m={...x,html:r("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",k).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:s,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:r(c).replace("hr",l).replace("heading"," *#{1,6} *[^\n]").replace("lheading",a).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},y=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,$=/^( {2,}|\\)\n(?!\s*$)/,R=/[\p{P}\p{S}]/u,S=/[\s\p{P}\p{S}]/u,T=/[^\s\p{P}\p{S}]/u,z=r(/^((?![*_])punctSpace)/,"u").replace(/punctSpace/g,S).getRegex(),A=r(/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,"u").replace(/punct/g,R).getRegex(),_=r("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),P=r("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,T).replace(/punctSpace/g,S).replace(/punct/g,R).getRegex(),I=r(/\\(punct)/,"gu").replace(/punct/g,R).getRegex(),L=r(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),B=r(k).replace("(?:--\x3e|$)","--\x3e").getRegex(),C=r("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",B).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),E=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,q=r(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",E).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Z=r(/^!?\[(label)\]\[(ref)\]/).replace("label",E).replace("ref",h).getRegex(),v=r(/^!?\[(ref)\](?:\[\])?/).replace("ref",h).getRegex(),D={_backpedal:s,anyPunctuation:I,autolink:L,blockSkip:/\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g,br:$,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:s,emStrongLDelim:A,emStrongRDelimAst:_,emStrongRDelimUnd:P,escape:y,link:q,nolink:v,punctuation:z,reflink:Z,reflinkSearch:r("reflink|nolink(?!\\()","g").replace("reflink",Z).replace("nolink",v).getRegex(),tag:C,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},H=e=>G[e];function X(e,t){if(t){if(i.escapeTest.test(e))return e.replace(i.escapeReplace,H)}else if(i.escapeTestNoEncode.test(e))return e.replace(i.escapeReplaceNoEncode,H);return e}function F(e){try{e=encodeURI(e).replace(i.percentDecode,"%")}catch{return null}return e}function U(e,t){const n=e.replace(i.findPipe,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(i.splitPipe);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:J(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t,n){const s=e.match(n.other.indentCodeCompensation);if(null===s)return t;const r=s[1];return t.split("\n").map((e=>{const t=e.match(n.other.beginningSpace);if(null===t)return e;const[s]=t;return s.length>=r.length?e.slice(r.length):e})).join("\n")}(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){const t=J(e,"#");this.options.pedantic?e=t.trim():t&&!this.rules.other.endingSpaceChar.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:J(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=J(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=this.rules.other.listItemRegex(n);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(this.rules.other.listReplaceTabs,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(this.rules.other.nonSpaceChar),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&this.rules.other.blankLine.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=this.rules.other.nextBulletRegex(p),n=this.rules.other.hrRegex(p),r=this.rules.other.fencesBeginRegex(p),i=this.rules.other.headingBeginRegex(p),l=this.rules.other.htmlBeginRegex(p);for(;e;){const u=e.split("\n",1)[0];let g;if(c=u,this.options.pedantic?(c=c.replace(this.rules.other.listReplaceNesting," "),g=c):g=c.replace(this.rules.other.tabCharGlobal," "),r.test(c))break;if(i.test(c))break;if(l.test(c))break;if(t.test(c))break;if(n.test(c))break;if(g.search(this.rules.other.nonSpaceChar)>=p||!c.trim())o+="\n"+g.slice(p);else{if(h)break;if(a.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4)break;if(r.test(a))break;if(i.test(a))break;if(n.test(a))break;o+="\n"+c}h||c.trim()||(h=!0),s+=u+"\n",e=e.substring(u.length+1),a=g.slice(p)}}r.loose||(l?r.loose=!0:this.rules.other.doubleBlankLine.test(s)&&(l=!0));let u,g=null;this.options.gfm&&(g=this.rules.other.listIsTask.exec(o),g&&(u="[ ] "!==g[0],o=o.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:s,task:!!g,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=s}const o=r.items.at(-1);if(!o)return;o.raw=o.raw.trimEnd(),o.text=o.text.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>this.rules.other.anyLine.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e({text:e,tokens:this.lexer.inline(e),header:!1,align:i.align[t]}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;const t=J(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=this.rules.other.pedanticHrefTitle.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),this.rules.other.startAngleBracket.test(n)&&(n=this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?n.slice(1):n.slice(1,-1)),K(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return K(n,e,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," ");const n=this.rules.other.nonSpaceChar.test(e),s=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return n&&s&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=t[1],n="mailto:"+e):(e=t[1],n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=t[0],n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=t[0],n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){const e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}}class W{tokens;options;state;tokenizer;inlineQueue;constructor(t){this.tokens=[],this.tokens.links=Object.create(null),this.options=t||e.defaults,this.options.tokenizer=this.options.tokenizer||new V,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};const n={other:i,block:j.normal,inline:N.normal};this.options.pedantic?(n.block=j.pedantic,n.inline=N.pedantic):this.options.gfm&&(n.block=j.gfm,this.options.breaks?n.inline=N.breaks:n.inline=N.gfm),this.tokenizer.rules=n}static get rules(){return{block:j,inline:N}}static lex(e,t){return new W(t).lex(e)}static lexInline(e,t){return new W(t).inlineTokens(e)}lex(e){e=e.replace(i.carriageReturn,"\n"),this.blockTokens(e,this.tokens);for(let e=0;e!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);const n=t.at(-1);1===s.raw.length&&void 0!==n?n.raw+="\n":t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.at(-1).src=n.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);const n=t.at(-1);"paragraph"===n?.type||"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.raw,this.inlineQueue.at(-1).src=n.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let r=e;if(this.options.extensions?.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(s=this.tokenizer.paragraph(r))){const i=t.at(-1);n&&"paragraph"===i?.type?(i.raw+="\n"+s.raw,i.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=i.text):t.push(s),n=r.length!==e.length,e=e.substring(s.raw.length)}else if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===n?.type?(n.raw+="\n"+s.raw,n.text+="\n"+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=n.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(s=this.tokenizer.rules.inline.reflinkSearch.exec(n));)e.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(s=this.tokenizer.rules.inline.blockSkip.exec(n));)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(s=this.tokenizer.rules.inline.anyPunctuation.exec(n));)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let r=!1,i="";for(;e;){let s;if(r||(i=""),r=!1,this.options.extensions?.inline?.some((n=>!!(s=n.call({lexer:this},e,t))&&(e=e.substring(s.raw.length),t.push(s),!0))))continue;if(s=this.tokenizer.escape(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.tag(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.link(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(s.raw.length);const n=t.at(-1);"text"===s.type&&"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s);continue}if(s=this.tokenizer.emStrong(e,n,i)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.codespan(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.br(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.del(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.autolink(e)){e=e.substring(s.raw.length),t.push(s);continue}if(!this.state.inLink&&(s=this.tokenizer.url(e))){e=e.substring(s.raw.length),t.push(s);continue}let l=e;if(this.options.extensions?.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(l=e.substring(0,t+1))}if(s=this.tokenizer.inlineText(l)){e=e.substring(s.raw.length),"_"!==s.raw.slice(-1)&&(i=s.raw.slice(-1)),r=!0;const n=t.at(-1);"text"===n?.type?(n.raw+=s.raw,n.text+=s.text):t.push(s)}else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return t}}class Y{options;parser;constructor(t){this.options=t||e.defaults}space(e){return""}code({text:e,lang:t,escaped:n}){const s=(t||"").match(i.notSpaceStart)?.[0],r=e.replace(i.endingNewline,"")+"\n";return s?'
'+(n?r:X(r,!0))+"
\n":"
"+(n?r:X(r,!0))+"
\n"}blockquote({tokens:e}){return`
\n${this.parser.parse(e)}
\n`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}\n`}hr(e){return"
\n"}list(e){const t=e.ordered,n=e.start;let s="";for(let t=0;t\n"+s+"\n"}listitem(e){let t="";if(e.task){const n=this.checkbox({checked:!!e.checked});e.loose?"paragraph"===e.tokens[0]?.type?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&"text"===e.tokens[0].tokens[0].type&&(e.tokens[0].tokens[0].text=n+" "+X(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`
  • ${t}
  • \n`}checkbox({checked:e}){return"'}paragraph({tokens:e}){return`

    ${this.parser.parseInline(e)}

    \n`}table(e){let t="",n="";for(let t=0;t${s}`),"\n\n"+t+"\n"+s+"
    \n"}tablerow({text:e}){return`\n${e}\n`}tablecell(e){const t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`\n`}strong({tokens:e}){return`${this.parser.parseInline(e)}`}em({tokens:e}){return`${this.parser.parseInline(e)}`}codespan({text:e}){return`${X(e,!0)}`}br(e){return"
    "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){const s=this.parser.parseInline(n),r=F(e);if(null===r)return s;let i='
    ",i}image({href:e,title:t,text:n}){const s=F(e);if(null===s)return X(n);let r=`${n}{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new Y(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new V(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new ne;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;const s=n,r=e.hooks[s],i=t[s];ne.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return W.lex(e,t??this.defaults)}parser(e,t){return te.parse(e,t??this.defaults)}parseMarkdown(e){return(t,n)=>{const s={...n},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(!0===this.defaults.async&&!1===s.async)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(null==t)return i(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof t)return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);const l=r.hooks?r.hooks.provideLexer():e?W.lex:W.lexInline,o=r.hooks?r.hooks.provideParser():e?te.parse:te.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(t):t).then((e=>l(e,r))).then((e=>r.hooks?r.hooks.processAllTokens(e):e)).then((e=>r.walkTokens?Promise.all(this.walkTokens(e,r.walkTokens)).then((()=>e)):e)).then((e=>o(e,r))).then((e=>r.hooks?r.hooks.postprocess(e):e)).catch(i);try{r.hooks&&(t=r.hooks.preprocess(t));let e=l(t,r);r.hooks&&(e=r.hooks.processAllTokens(e)),r.walkTokens&&this.walkTokens(e,r.walkTokens);let n=o(e,r);return r.hooks&&(n=r.hooks.postprocess(n)),n}catch(e){return i(e)}}}onError(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+X(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const re=new se;function ie(e,t){return re.parse(e,t)}ie.options=ie.setOptions=function(e){return re.setOptions(e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.getDefaults=t,ie.defaults=e.defaults,ie.use=function(...e){return re.use(...e),ie.defaults=re.defaults,n(ie.defaults),ie},ie.walkTokens=function(e,t){return re.walkTokens(e,t)},ie.parseInline=re.parseInline,ie.Parser=te,ie.parser=te.parse,ie.Renderer=Y,ie.TextRenderer=ee,ie.Lexer=W,ie.lexer=W.lex,ie.Tokenizer=V,ie.Hooks=ne,ie.parse=ie;const le=ie.options,oe=ie.setOptions,ae=ie.use,ce=ie.walkTokens,he=ie.parseInline,pe=ie,ue=te.parse,ge=W.lex;e.Hooks=ne,e.Lexer=W,e.Marked=se,e.Parser=te,e.Renderer=Y,e.TextRenderer=ee,e.Tokenizer=V,e.getDefaults=t,e.lexer=ge,e.marked=ie,e.options=le,e.parse=pe,e.parseInline=he,e.parser=ue,e.setOptions=oe,e.use=ae,e.walkTokens=ce})); diff --git a/packages/coding-agent/src/core/hooks/index.ts b/packages/coding-agent/src/core/hooks/index.ts index cb0806ed..9729ecbc 100644 --- a/packages/coding-agent/src/core/hooks/index.ts +++ b/packages/coding-agent/src/core/hooks/index.ts @@ -3,11 +3,14 @@ export { discoverAndLoadHooks, loadHooks, type AppendEntryHandler, + type BranchHandler, type LoadedHook, type LoadHooksResult, + type NavigateTreeHandler, + type NewSessionHandler, type SendMessageHandler, } from "./loader.js"; export { execCommand, HookRunner, type HookErrorListener } from "./runner.js"; export { wrapToolsWithHooks, wrapToolWithHooks } from "./tool-wrapper.js"; -export type * from "./types.js"; +export * from "./types.js"; export type { ReadonlySessionManager } from "../session-manager.js"; diff --git a/packages/coding-agent/src/core/hooks/loader.ts b/packages/coding-agent/src/core/hooks/loader.ts index 3ac44b27..e0bbe9ac 100644 --- a/packages/coding-agent/src/core/hooks/loader.ts +++ b/packages/coding-agent/src/core/hooks/loader.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from "node:url"; import { createJiti } from "jiti"; import { getAgentDir } from "../../config.js"; import type { HookMessage } from "../messages.js"; +import type { SessionManager } from "../session-manager.js"; import { execCommand } from "./runner.js"; import type { ExecOptions, HookAPI, HookFactory, HookMessageRenderer, RegisteredCommand } from "./types.js"; @@ -52,7 +53,7 @@ type HandlerFn = (...args: unknown[]) => Promise; */ export type SendMessageHandler = ( message: Pick, "customType" | "content" | "display" | "details">, - triggerTurn?: boolean, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, ) => void; /** @@ -60,6 +61,27 @@ export type SendMessageHandler = ( */ export type AppendEntryHandler = (customType: string, data?: T) => void; +/** + * New session handler type for ctx.newSession() in HookCommandContext. + */ +export type NewSessionHandler = (options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; +}) => Promise<{ cancelled: boolean }>; + +/** + * Branch handler type for ctx.branch() in HookCommandContext. + */ +export type BranchHandler = (entryId: string) => Promise<{ cancelled: boolean }>; + +/** + * Navigate tree handler type for ctx.navigateTree() in HookCommandContext. + */ +export type NavigateTreeHandler = ( + targetId: string, + options?: { summarize?: boolean }, +) => Promise<{ cancelled: boolean }>; + /** * Registered handlers for a loaded hook. */ @@ -126,7 +148,7 @@ function resolveHookPath(hookPath: string, cwd: string): string { /** * Create a HookAPI instance that collects handlers, renderers, and commands. - * Returns the API, maps, and a function to set the send message handler later. + * Returns the API, maps, and functions to set handlers later. */ function createHookAPI( handlers: Map, @@ -155,8 +177,11 @@ function createHookAPI( list.push(handler); handlers.set(event, list); }, - sendMessage(message: HookMessage, triggerTurn?: boolean): void { - sendMessageHandler(message, triggerTurn); + sendMessage( + message: HookMessage, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, + ): void { + sendMessageHandler(message, options); }, appendEntry(customType: string, data?: T): void { appendEntryHandler(customType, data); diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index 04a7eae3..65dfe3f7 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -4,14 +4,23 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { Model } from "@mariozechner/pi-ai"; +import { theme } from "../../modes/interactive/theme/theme.js"; import type { ModelRegistry } from "../model-registry.js"; import type { SessionManager } from "../session-manager.js"; -import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js"; +import type { + AppendEntryHandler, + BranchHandler, + LoadedHook, + NavigateTreeHandler, + NewSessionHandler, + SendMessageHandler, +} from "./loader.js"; import type { BeforeAgentStartEvent, BeforeAgentStartEventResult, ContextEvent, ContextEventResult, + HookCommandContext, HookContext, HookError, HookEvent, @@ -25,11 +34,6 @@ import type { ToolResultEventResult, } from "./types.js"; -/** - * Default timeout for hook execution (30 seconds). - */ -const DEFAULT_TIMEOUT = 30000; - /** * Listener for hook errors. */ @@ -38,27 +42,20 @@ export type HookErrorListener = (error: HookError) => void; // Re-export execCommand for backward compatibility export { execCommand } from "../exec.js"; -/** - * Create a promise that rejects after a timeout. - */ -function createTimeout(ms: number): { promise: Promise; clear: () => void } { - let timeoutId: NodeJS.Timeout; - const promise = new Promise((_, reject) => { - timeoutId = setTimeout(() => reject(new Error(`Hook timed out after ${ms}ms`)), ms); - }); - return { - promise, - clear: () => clearTimeout(timeoutId), - }; -} - /** No-op UI context used when no UI is available */ const noOpUIContext: HookUIContext = { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + setStatus: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return theme; + }, }; /** @@ -71,24 +68,23 @@ export class HookRunner { private cwd: string; private sessionManager: SessionManager; private modelRegistry: ModelRegistry; - private timeout: number; private errorListeners: Set = new Set(); private getModel: () => Model | undefined = () => undefined; + private isIdleFn: () => boolean = () => true; + private waitForIdleFn: () => Promise = async () => {}; + private abortFn: () => void = () => {}; + private hasPendingMessagesFn: () => boolean = () => false; + private newSessionHandler: NewSessionHandler = async () => ({ cancelled: false }); + private branchHandler: BranchHandler = async () => ({ cancelled: false }); + private navigateTreeHandler: NavigateTreeHandler = async () => ({ cancelled: false }); - constructor( - hooks: LoadedHook[], - cwd: string, - sessionManager: SessionManager, - modelRegistry: ModelRegistry, - timeout: number = DEFAULT_TIMEOUT, - ) { + constructor(hooks: LoadedHook[], cwd: string, sessionManager: SessionManager, modelRegistry: ModelRegistry) { this.hooks = hooks; this.uiContext = noOpUIContext; this.hasUI = false; this.cwd = cwd; this.sessionManager = sessionManager; this.modelRegistry = modelRegistry; - this.timeout = timeout; } /** @@ -102,26 +98,47 @@ export class HookRunner { sendMessageHandler: SendMessageHandler; /** Handler for hooks to append entries */ appendEntryHandler: AppendEntryHandler; + /** Handler for creating new sessions (for HookCommandContext) */ + newSessionHandler?: NewSessionHandler; + /** Handler for branching sessions (for HookCommandContext) */ + branchHandler?: BranchHandler; + /** Handler for navigating session tree (for HookCommandContext) */ + navigateTreeHandler?: NavigateTreeHandler; + /** Function to check if agent is idle */ + isIdle?: () => boolean; + /** Function to wait for agent to be idle */ + waitForIdle?: () => Promise; + /** Function to abort current operation (fire-and-forget) */ + abort?: () => void; + /** Function to check if there are queued messages */ + hasPendingMessages?: () => boolean; /** UI context for interactive prompts */ uiContext?: HookUIContext; /** Whether UI is available */ hasUI?: boolean; }): void { this.getModel = options.getModel; - this.setSendMessageHandler(options.sendMessageHandler); - this.setAppendEntryHandler(options.appendEntryHandler); - if (options.uiContext) { - this.setUIContext(options.uiContext, options.hasUI ?? false); + this.isIdleFn = options.isIdle ?? (() => true); + this.waitForIdleFn = options.waitForIdle ?? (async () => {}); + this.abortFn = options.abort ?? (() => {}); + this.hasPendingMessagesFn = options.hasPendingMessages ?? (() => false); + // Store session handlers for HookCommandContext + if (options.newSessionHandler) { + this.newSessionHandler = options.newSessionHandler; } - } - - /** - * Set the UI context for hooks. - * Call this when the mode initializes and UI is available. - */ - setUIContext(uiContext: HookUIContext, hasUI: boolean): void { - this.uiContext = uiContext; - this.hasUI = hasUI; + if (options.branchHandler) { + this.branchHandler = options.branchHandler; + } + if (options.navigateTreeHandler) { + this.navigateTreeHandler = options.navigateTreeHandler; + } + // Set per-hook handlers for pi.sendMessage() and pi.appendEntry() + for (const hook of this.hooks) { + hook.setSendMessageHandler(options.sendMessageHandler); + hook.setAppendEntryHandler(options.appendEntryHandler); + } + this.uiContext = options.uiContext ?? noOpUIContext; + this.hasUI = options.hasUI ?? false; } /** @@ -145,26 +162,6 @@ export class HookRunner { return this.hooks.map((h) => h.path); } - /** - * Set the send message handler for all hooks' pi.sendMessage(). - * Call this when the mode initializes. - */ - setSendMessageHandler(handler: SendMessageHandler): void { - for (const hook of this.hooks) { - hook.setSendMessageHandler(handler); - } - } - - /** - * Set the append entry handler for all hooks' pi.appendEntry(). - * Call this when the mode initializes. - */ - setAppendEntryHandler(handler: AppendEntryHandler): void { - for (const hook of this.hooks) { - hook.setAppendEntryHandler(handler); - } - } - /** * Subscribe to hook errors. * @returns Unsubscribe function @@ -251,6 +248,23 @@ export class HookRunner { sessionManager: this.sessionManager, modelRegistry: this.modelRegistry, model: this.getModel(), + isIdle: () => this.isIdleFn(), + abort: () => this.abortFn(), + hasPendingMessages: () => this.hasPendingMessagesFn(), + }; + } + + /** + * Create the command context for slash command handlers. + * Extends HookContext with session control methods that are only safe in commands. + */ + createCommandContext(): HookCommandContext { + return { + ...this.createContext(), + waitForIdle: () => this.waitForIdleFn(), + newSession: (options) => this.newSessionHandler(options), + branch: (entryId) => this.branchHandler(entryId), + navigateTree: (targetId, options) => this.navigateTreeHandler(targetId, options), }; } @@ -259,15 +273,9 @@ export class HookRunner { */ private isSessionBeforeEvent( type: string, - ): type is - | "session_before_switch" - | "session_before_new" - | "session_before_branch" - | "session_before_compact" - | "session_before_tree" { + ): type is "session_before_switch" | "session_before_branch" | "session_before_compact" | "session_before_tree" { return ( type === "session_before_switch" || - type === "session_before_new" || type === "session_before_branch" || type === "session_before_compact" || type === "session_before_tree" @@ -290,16 +298,7 @@ export class HookRunner { for (const handler of handlers) { try { - // No timeout for session_before_compact events (like tool_call, they may take a while) - let handlerResult: unknown; - - if (event.type === "session_before_compact") { - handlerResult = await handler(event, ctx); - } else { - const timeout = createTimeout(this.timeout); - handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); - } + const handlerResult = await handler(event, ctx); // For session before_* events, capture the result (for cancellation) if (this.isSessionBeforeEvent(event.type) && handlerResult) { @@ -376,9 +375,7 @@ export class HookRunner { for (const handler of handlers) { try { const event: ContextEvent = { type: "context", messages: currentMessages }; - const timeout = createTimeout(this.timeout); - const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); + const handlerResult = await handler(event, ctx); if (handlerResult && (handlerResult as ContextEventResult).messages) { currentMessages = (handlerResult as ContextEventResult).messages!; @@ -415,9 +412,7 @@ export class HookRunner { for (const handler of handlers) { try { const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; - const timeout = createTimeout(this.timeout); - const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]); - timeout.clear(); + const handlerResult = await handler(event, ctx); // Take the first message returned if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) { diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts index c3499d9f..28c718f0 100644 --- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts +++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts @@ -46,30 +46,46 @@ export function wrapToolWithHooks(tool: AgentTool, hookRunner: HookRu } // Execute the actual tool, forwarding onUpdate for progress streaming - const result = await tool.execute(toolCallId, params, signal, onUpdate); + try { + const result = await tool.execute(toolCallId, params, signal, onUpdate); - // Emit tool_result event - hooks can modify the result - if (hookRunner.hasHandlers("tool_result")) { - const resultResult = (await hookRunner.emit({ - type: "tool_result", - toolName: tool.name, - toolCallId, - input: params, - content: result.content, - details: result.details, - isError: false, - })) as ToolResultEventResult | undefined; + // Emit tool_result event - hooks can modify the result + if (hookRunner.hasHandlers("tool_result")) { + const resultResult = (await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: result.content, + details: result.details, + isError: false, + })) as ToolResultEventResult | undefined; - // Apply modifications if any - if (resultResult) { - return { - content: resultResult.content ?? result.content, - details: (resultResult.details ?? result.details) as T, - }; + // Apply modifications if any + if (resultResult) { + return { + content: resultResult.content ?? result.content, + details: (resultResult.details ?? result.details) as T, + }; + } } - } - return result; + return result; + } catch (err) { + // Emit tool_result event for errors so hooks can observe failures + if (hookRunner.hasHandlers("tool_result")) { + await hookRunner.emit({ + type: "tool_result", + toolName: tool.name, + toolCallId, + input: params, + content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }], + details: undefined, + isError: true, + }); + } + throw err; // Re-throw original error for agent-loop + } }, }; } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 2d3cd141..50597c42 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -7,33 +7,19 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai"; -import type { Component } from "@mariozechner/pi-tui"; +import type { Component, TUI } from "@mariozechner/pi-tui"; import type { Theme } from "../../modes/interactive/theme/theme.js"; import type { CompactionPreparation, CompactionResult } from "../compaction/index.js"; import type { ExecOptions, ExecResult } from "../exec.js"; import type { HookMessage } from "../messages.js"; import type { ModelRegistry } from "../model-registry.js"; -import type { BranchSummaryEntry, CompactionEntry, SessionEntry, SessionManager } from "../session-manager.js"; - -/** - * Read-only view of SessionManager for hooks. - * Hooks should use pi.sendMessage() and pi.appendEntry() for writes. - */ -export type ReadonlySessionManager = Pick< +import type { + BranchSummaryEntry, + CompactionEntry, + ReadonlySessionManager, + SessionEntry, SessionManager, - | "getCwd" - | "getSessionDir" - | "getSessionId" - | "getSessionFile" - | "getLeafId" - | "getLeafEntry" - | "getEntry" - | "getLabel" - | "getPath" - | "getHeader" - | "getEntries" - | "getTree" ->; +} from "../session-manager.js"; import type { EditToolDetails } from "../tools/edit.js"; import type { @@ -78,17 +64,84 @@ export interface HookUIContext { notify(message: string, type?: "info" | "warning" | "error"): void; /** - * Show a custom component with keyboard focus. - * The component receives keyboard input via handleInput() if implemented. - * - * @param component - Component to display (implement handleInput for keyboard, dispose for cleanup) - * @returns Object with close() to restore normal UI and requestRender() to trigger redraw + * Set status text in the footer/status bar. + * Pass undefined as text to clear the status for this key. + * Text can include ANSI escape codes for styling. + * Note: Newlines, tabs, and carriage returns are replaced with spaces. + * The combined status line is truncated to terminal width. + * @param key - Unique key to identify this status (e.g., hook name) + * @param text - Status text to display, or undefined to clear */ - custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void }; + setStatus(key: string, text: string | undefined): void; + + /** + * Show a custom component with keyboard focus. + * The factory receives TUI, theme, and a done() callback to close the component. + * Can be async for fire-and-forget work (don't await the work, just start it). + * + * @param factory - Function that creates the component. Call done() when finished. + * @returns Promise that resolves with the value passed to done() + * + * @example + * // Sync factory + * const result = await ctx.ui.custom((tui, theme, done) => { + * const component = new MyComponent(tui, theme); + * component.onFinish = (value) => done(value); + * return component; + * }); + * + * // Async factory with fire-and-forget work + * const result = await ctx.ui.custom(async (tui, theme, done) => { + * const loader = new CancellableLoader(tui, theme.fg("accent"), theme.fg("muted"), "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); // Don't await - fire and forget + * return loader; + * }); + */ + custom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise; + + /** + * Set the text in the core input editor. + * Use this to pre-fill the input box with generated content (e.g., prompt templates, extracted questions). + * @param text - Text to set in the editor + */ + setEditorText(text: string): void; + + /** + * Get the current text from the core input editor. + * @returns Current editor text + */ + getEditorText(): string; + + /** + * Show a multi-line editor for text editing. + * Supports Ctrl+G to open external editor ($VISUAL or $EDITOR). + * @param title - Title describing what is being edited + * @param prefill - Optional initial text + * @returns Edited text, or undefined if cancelled (Escape) + */ + editor(title: string, prefill?: string): Promise; + + /** + * Get the current theme for styling text with ANSI codes. + * Use theme.fg() and theme.bg() to style status text. + * + * @example + * const theme = ctx.ui.theme; + * ctx.ui.setStatus("my-hook", theme.fg("success", "โœ“") + " Ready"); + */ + readonly theme: Theme; } /** - * Context passed to hook event and command handlers. + * Context passed to hook event handlers. + * For command handlers, see HookCommandContext which extends this with session control methods. */ export interface HookContext { /** UI methods for user interaction */ @@ -103,6 +156,63 @@ export interface HookContext { modelRegistry: ModelRegistry; /** Current model (may be undefined if no model is selected yet) */ model: Model | undefined; + /** Whether the agent is idle (not streaming) */ + isIdle(): boolean; + /** Abort the current agent operation (fire-and-forget, does not wait) */ + abort(): void; + /** Whether there are queued messages waiting to be processed */ + hasPendingMessages(): boolean; +} + +/** + * Extended context for slash command handlers. + * Includes session control methods that are only safe in user-initiated commands. + * + * These methods are not available in event handlers because they can cause + * deadlocks when called from within the agent loop (e.g., tool_call, context events). + */ +export interface HookCommandContext extends HookContext { + /** Wait for the agent to finish streaming */ + waitForIdle(): Promise; + + /** + * Start a new session, optionally with a setup callback to initialize it. + * The setup callback receives a writable SessionManager for the new session. + * + * @param options.parentSession - Path to parent session for lineage tracking + * @param options.setup - Async callback to initialize the new session (e.g., append messages) + * @returns Object with `cancelled: true` if a hook cancelled the new session + * + * @example + * // Handoff: summarize current session and start fresh with context + * await ctx.newSession({ + * parentSession: ctx.sessionManager.getSessionFile(), + * setup: async (sm) => { + * sm.appendMessage({ role: "user", content: [{ type: "text", text: summary }] }); + * } + * }); + */ + newSession(options?: { + parentSession?: string; + setup?: (sessionManager: SessionManager) => Promise; + }): Promise<{ cancelled: boolean }>; + + /** + * Branch from a specific entry, creating a new session file. + * + * @param entryId - ID of the entry to branch from + * @returns Object with `cancelled: true` if a hook cancelled the branch + */ + branch(entryId: string): Promise<{ cancelled: boolean }>; + + /** + * Navigate to a different point in the session tree (in-place). + * + * @param targetId - ID of the entry to navigate to + * @param options.summarize - Whether to summarize the abandoned branch + * @returns Object with `cancelled: true` if a hook cancelled the navigation + */ + navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ cancelled: boolean }>; } // ============================================================================ @@ -117,32 +227,26 @@ export interface SessionStartEvent { /** Fired before switching to another session (can be cancelled) */ export interface SessionBeforeSwitchEvent { type: "session_before_switch"; - /** Session file we're switching to */ - targetSessionFile: string; + /** Reason for the switch */ + reason: "new" | "resume"; + /** Session file we're switching to (only for "resume") */ + targetSessionFile?: string; } /** Fired after switching to another session */ export interface SessionSwitchEvent { type: "session_switch"; + /** Reason for the switch */ + reason: "new" | "resume"; /** Session file we came from */ previousSessionFile: string | undefined; } -/** Fired before creating a new session (can be cancelled) */ -export interface SessionBeforeNewEvent { - type: "session_before_new"; -} - -/** Fired after creating a new session */ -export interface SessionNewEvent { - type: "session_new"; -} - /** Fired before branching a session (can be cancelled) */ export interface SessionBeforeBranchEvent { type: "session_before_branch"; - /** Index of the entry in the session (SessionManager.getEntries()) to branch from */ - entryIndex: number; + /** ID of the entry to branch from */ + entryId: string; } /** Fired after branching a session */ @@ -218,8 +322,6 @@ export type SessionEvent = | SessionStartEvent | SessionBeforeSwitchEvent | SessionSwitchEvent - | SessionBeforeNewEvent - | SessionNewEvent | SessionBeforeBranchEvent | SessionBranchEvent | SessionBeforeCompactEvent @@ -468,12 +570,6 @@ export interface SessionBeforeSwitchResult { cancel?: boolean; } -/** Return type for session_before_new handlers */ -export interface SessionBeforeNewResult { - /** If true, cancel the new session */ - cancel?: boolean; -} - /** Return type for session_before_branch handlers */ export interface SessionBeforeBranchResult { /** @@ -551,7 +647,7 @@ export interface RegisteredCommand { name: string; description?: string; - handler: (args: string, ctx: HookContext) => Promise; + handler: (args: string, ctx: HookCommandContext) => Promise; } /** @@ -563,8 +659,6 @@ export interface HookAPI { on(event: "session_start", handler: HookHandler): void; on(event: "session_before_switch", handler: HookHandler): void; on(event: "session_switch", handler: HookHandler): void; - on(event: "session_before_new", handler: HookHandler): void; - on(event: "session_new", handler: HookHandler): void; on(event: "session_before_branch", handler: HookHandler): void; on(event: "session_branch", handler: HookHandler): void; on( @@ -598,12 +692,15 @@ export interface HookAPI { * @param message.content - Message content (string or TextContent/ImageContent array) * @param message.display - Whether to show in TUI (true = styled display, false = hidden) * @param message.details - Optional hook-specific metadata (not sent to LLM) - * @param triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. - * If agent is streaming, message is queued and triggerTurn is ignored. + * @param options.triggerTurn - If true and agent is idle, triggers a new LLM turn. Default: false. + * If agent is streaming, message is queued and triggerTurn is ignored. + * @param options.deliverAs - How to deliver when agent is streaming. Default: "steer". + * - "steer": Interrupt mid-run, delivered after current tool execution. + * - "followUp": Wait until agent finishes all work before delivery. */ sendMessage( message: Pick, "customType" | "content" | "display" | "details">, - triggerTurn?: boolean, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, ): void; /** @@ -643,7 +740,7 @@ export interface HookAPI { /** * Register a custom slash command. - * Handler receives HookCommandContext. + * Handler receives HookCommandContext with session control methods. */ registerCommand(name: string, options: { description?: string; handler: RegisteredCommand["handler"] }): void; diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 55e51d99..4b15f6fe 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -14,16 +14,16 @@ export { export { type BashExecutorOptions, type BashResult, executeBash } from "./bash-executor.js"; export type { CompactionResult } from "./compaction/index.js"; export { - type CustomAgentTool, + type CustomTool, + type CustomToolAPI, type CustomToolFactory, type CustomToolsLoadResult, + type CustomToolUIContext, discoverAndLoadCustomTools, type ExecResult, type LoadedCustomTool, loadCustomTools, type RenderResultOptions, - type ToolAPI, - type ToolUIContext, } from "./custom-tools/index.js"; export { type HookAPI, diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts index 981f11f2..d2124413 100644 --- a/packages/coding-agent/src/core/model-resolver.ts +++ b/packages/coding-agent/src/core/model-resolver.ts @@ -5,6 +5,7 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai"; import chalk from "chalk"; +import { minimatch } from "minimatch"; import { isValidThinkingLevel } from "../cli/args.js"; import type { ModelRegistry } from "./model-registry.js"; @@ -172,6 +173,41 @@ export async function resolveModelScope(patterns: string[], modelRegistry: Model const scopedModels: ScopedModel[] = []; for (const pattern of patterns) { + // Check if pattern contains glob characters + if (pattern.includes("*") || pattern.includes("?") || pattern.includes("[")) { + // Extract optional thinking level suffix (e.g., "provider/*:high") + const colonIdx = pattern.lastIndexOf(":"); + let globPattern = pattern; + let thinkingLevel: ThinkingLevel = "off"; + + if (colonIdx !== -1) { + const suffix = pattern.substring(colonIdx + 1); + if (isValidThinkingLevel(suffix)) { + thinkingLevel = suffix; + globPattern = pattern.substring(0, colonIdx); + } + } + + // Match against "provider/modelId" format OR just model ID + // This allows "*sonnet*" to match without requiring "anthropic/*sonnet*" + const matchingModels = availableModels.filter((m) => { + const fullId = `${m.provider}/${m.id}`; + return minimatch(fullId, globPattern, { nocase: true }) || minimatch(m.id, globPattern, { nocase: true }); + }); + + if (matchingModels.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + for (const model of matchingModels) { + if (!scopedModels.find((sm) => modelsAreEqual(sm.model, model))) { + scopedModels.push({ model, thinkingLevel }); + } + } + continue; + } + const { model, thinkingLevel, warning } = parseModelPattern(pattern, availableModels); if (warning) { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 089691d3..5d0749d2 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -35,8 +35,13 @@ import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; -import { discoverAndLoadCustomTools, type LoadedCustomTool } from "./custom-tools/index.js"; -import type { CustomAgentTool } from "./custom-tools/types.js"; +import { + type CustomToolsLoadResult, + discoverAndLoadCustomTools, + type LoadedCustomTool, + wrapCustomTools, +} from "./custom-tools/index.js"; +import type { CustomTool } from "./custom-tools/types.js"; import { discoverAndLoadHooks, HookRunner, type LoadedHook, wrapToolsWithHooks } from "./hooks/index.js"; import type { HookFactory } from "./hooks/types.js"; import { convertToLlm } from "./messages.js"; @@ -99,7 +104,7 @@ export interface CreateAgentSessionOptions { /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; /** Custom tools (replaces discovery). */ - customTools?: Array<{ path?: string; tool: CustomAgentTool }>; + customTools?: Array<{ path?: string; tool: CustomTool }>; /** Additional custom tool paths to load (merged with discovery). */ additionalCustomToolPaths?: string[]; @@ -127,18 +132,15 @@ export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; /** Custom tools result (for UI context setup in interactive mode) */ - customToolsResult: { - tools: LoadedCustomTool[]; - setUIContext: (uiContext: any, hasUI: boolean) => void; - }; + customToolsResult: CustomToolsLoadResult; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; } // Re-exports -export type { CustomAgentTool } from "./custom-tools/types.js"; -export type { HookAPI, HookFactory } from "./hooks/types.js"; +export type { CustomTool } from "./custom-tools/types.js"; +export type { HookAPI, HookCommandContext, HookContext, HookFactory } from "./hooks/types.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { FileSlashCommand } from "./slash-commands.js"; @@ -219,7 +221,7 @@ export async function discoverHooks( export async function discoverCustomTools( cwd?: string, agentDir?: string, -): Promise> { +): Promise> { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); @@ -303,7 +305,8 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { defaultProvider: manager.getDefaultProvider(), defaultModel: manager.getDefaultModel(), defaultThinkingLevel: manager.getDefaultThinkingLevel(), - queueMode: manager.getQueueMode(), + steeringMode: manager.getSteeringMode(), + followUpMode: manager.getFollowUpMode(), theme: manager.getTheme(), compaction: manager.getCompactionSettings(), retry: manager.getRetrySettings(), @@ -311,7 +314,6 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { shellPath: manager.getShellPath(), collapseChangelog: manager.getCollapseChangelog(), hooks: manager.getHookPaths(), - hookTimeout: manager.getHookTimeout(), customTools: manager.getCustomToolPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, @@ -342,8 +344,16 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa const handlers = new Map Promise>>(); const messageRenderers = new Map(); const commands = new Map(); - let sendMessageHandler: (message: any, triggerTurn?: boolean) => void = () => {}; + let sendMessageHandler: ( + message: any, + options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }, + ) => void = () => {}; let appendEntryHandler: (customType: string, data?: any) => void = () => {}; + let newSessionHandler: (options?: any) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); + let branchHandler: (entryId: string) => Promise<{ cancelled: boolean }> = async () => ({ cancelled: false }); + let navigateTreeHandler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }> = async () => ({ + cancelled: false, + }); const api = { on: (event: string, handler: (...args: unknown[]) => Promise) => { @@ -351,8 +361,8 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa list.push(handler); handlers.set(event, list); }, - sendMessage: (message: any, triggerTurn?: boolean) => { - sendMessageHandler(message, triggerTurn); + sendMessage: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => { + sendMessageHandler(message, options); }, appendEntry: (customType: string, data?: any) => { appendEntryHandler(customType, data); @@ -363,6 +373,9 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa registerCommand: (name: string, options: any) => { commands.set(name, { name, ...options }); }, + newSession: (options?: any) => newSessionHandler(options), + branch: (entryId: string) => branchHandler(entryId), + navigateTree: (targetId: string, options?: any) => navigateTreeHandler(targetId, options), }; def.factory(api as any); @@ -373,12 +386,23 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa handlers, messageRenderers, commands, - setSendMessageHandler: (handler: (message: any, triggerTurn?: boolean) => void) => { + setSendMessageHandler: ( + handler: (message: any, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" }) => void, + ) => { sendMessageHandler = handler; }, setAppendEntryHandler: (handler: (customType: string, data?: any) => void) => { appendEntryHandler = handler; }, + setNewSessionHandler: (handler: (options?: any) => Promise<{ cancelled: boolean }>) => { + newSessionHandler = handler; + }, + setBranchHandler: (handler: (entryId: string) => Promise<{ cancelled: boolean }>) => { + branchHandler = handler; + }, + setNavigateTreeHandler: (handler: (targetId: string, options?: any) => Promise<{ cancelled: boolean }>) => { + navigateTreeHandler = handler; + }, }; }); } @@ -504,10 +528,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); time("discoverContextFiles"); - const builtInTools = options.tools ?? createCodingTools(cwd); + const autoResizeImages = settingsManager.getImageAutoResize(); + const builtInTools = options.tools ?? createCodingTools(cwd, { read: { autoResizeImages } }); time("createCodingTools"); - let customToolsResult: { tools: LoadedCustomTool[]; setUIContext: (ctx: any, hasUI: boolean) => void }; + let customToolsResult: CustomToolsLoadResult; if (options.customTools !== undefined) { // Use provided custom tools const loadedTools: LoadedCustomTool[] = options.customTools.map((ct) => ({ @@ -517,24 +542,24 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} })); customToolsResult = { tools: loadedTools, + errors: [], setUIContext: () => {}, }; } else { // Discover custom tools, merging with additional paths const configuredPaths = [...settingsManager.getCustomToolPaths(), ...(options.additionalCustomToolPaths ?? [])]; - const result = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); + customToolsResult = await discoverAndLoadCustomTools(configuredPaths, cwd, Object.keys(allTools), agentDir); time("discoverAndLoadCustomTools"); - for (const { path, error } of result.errors) { + for (const { path, error } of customToolsResult.errors) { console.error(`Failed to load custom tool "${path}": ${error}`); } - customToolsResult = result; } let hookRunner: HookRunner | undefined; if (options.hooks !== undefined) { if (options.hooks.length > 0) { const loadedHooks = createLoadedHooksFromDefinitions(options.hooks); - hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(loadedHooks, cwd, sessionManager, modelRegistry); } } else { // Discover hooks, merging with additional paths @@ -545,11 +570,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} console.error(`Failed to load hook "${path}": ${error}`); } if (hooks.length > 0) { - hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry, settingsManager.getHookTimeout()); + hookRunner = new HookRunner(hooks, cwd, sessionManager, modelRegistry); } } - let allToolsArray: Tool[] = [...builtInTools, ...customToolsResult.tools.map((lt) => lt.tool as unknown as Tool)]; + // Wrap custom tools with context getter (agent/session assigned below, accessed at execute time) + let agent: Agent; + let session: AgentSession; + const wrappedCustomTools = wrapCustomTools(customToolsResult.tools, () => ({ + sessionManager, + modelRegistry, + model: agent.state.model, + isIdle: () => !session.isStreaming, + hasPendingMessages: () => session.pendingMessageCount > 0, + abort: () => { + session.abort(); + }, + })); + + let allToolsArray: Tool[] = [...builtInTools, ...wrappedCustomTools]; time("combineTools"); if (hookRunner) { allToolsArray = wrapToolsWithHooks(allToolsArray, hookRunner) as Tool[]; @@ -581,7 +620,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const slashCommands = options.slashCommands ?? discoverSlashCommands(cwd, agentDir); time("discoverSlashCommands"); - const agent = new Agent({ + agent = new Agent({ initialState: { systemPrompt, model, @@ -594,7 +633,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return hookRunner.emitContext(messages); } : undefined, - queueMode: settingsManager.getQueueMode(), + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), getApiKey: async () => { const currentModel = agent.state.model; if (!currentModel) { @@ -612,9 +652,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Restore messages if session has existing data if (hasExistingSession) { agent.replaceMessages(existingSession.messages); + } else { + // Save initial model and thinking level for new sessions so they can be restored on resume + if (model) { + sessionManager.appendModelChange(model.provider, model.id); + } + sessionManager.appendThinkingLevelChange(thinkingLevel); } - const session = new AgentSession({ + session = new AgentSession({ agent, sessionManager, settingsManager, diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index c3e17714..6698ccad 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -31,7 +31,11 @@ export interface SessionHeader { id: string; timestamp: string; cwd: string; - branchedFrom?: string; + parentSession?: string; +} + +export interface NewSessionOptions { + parentSession?: string; } export interface SessionEntryBase { @@ -159,19 +163,21 @@ export interface SessionInfo { allMessagesText: string; } -/** - * Read-only interface for SessionManager. - * Used by compaction/summarization utilities that only need to read session data. - */ -export interface ReadonlySessionManager { - getLeafId(): string | null; - getEntry(id: string): SessionEntry | undefined; - getPath(fromId?: string): SessionEntry[]; - getEntries(): SessionEntry[]; - getChildren(parentId: string): SessionEntry[]; - getTree(): SessionTreeNode[]; - getLabel(id: string): string | undefined; -} +export type ReadonlySessionManager = Pick< + SessionManager, + | "getCwd" + | "getSessionDir" + | "getSessionId" + | "getSessionFile" + | "getLeafId" + | "getLeafEntry" + | "getEntry" + | "getLabel" + | "getBranch" + | "getHeader" + | "getEntries" + | "getTree" +>; /** Generate a unique short ID (8 hex chars, collision-checked) */ function generateId(byId: { has(id: string): boolean }): string { @@ -506,7 +512,7 @@ export class SessionManager { } } - newSession(): string | undefined { + newSession(options?: NewSessionOptions): string | undefined { this.sessionId = randomUUID(); const timestamp = new Date().toISOString(); const header: SessionHeader = { @@ -515,11 +521,13 @@ export class SessionManager { id: this.sessionId, timestamp, cwd: this.cwd, + parentSession: options?.parentSession, }; this.fileEntries = [header]; this.byId.clear(); this.leafId = null; this.flushed = false; + // Only generate filename if persisting and not already set (e.g., via --session flag) if (this.persist && !this.sessionFile) { const fileTimestamp = timestamp.replace(/[:.]/g, "-"); @@ -772,7 +780,7 @@ export class SessionManager { * Includes all entry types (messages, compaction, model changes, etc.). * Use buildSessionContext() to get the resolved messages for the LLM. */ - getPath(fromId?: string): SessionEntry[] { + getBranch(fromId?: string): SessionEntry[] { const path: SessionEntry[] = []; const startId = fromId ?? this.leafId; let current = startId ? this.byId.get(startId) : undefined; @@ -908,7 +916,7 @@ export class SessionManager { * Returns the new session file path, or undefined if not persisting. */ createBranchedSession(leafId: string): string | undefined { - const path = this.getPath(leafId); + const path = this.getBranch(leafId); if (path.length === 0) { throw new Error(`Entry ${leafId} not found`); } @@ -927,7 +935,7 @@ export class SessionManager { id: newSessionId, timestamp, cwd: this.cwd, - branchedFrom: this.persist ? this.sessionFile : undefined, + parentSession: this.persist ? this.sessionFile : undefined, }; // Collect labels for entries in the path diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3a116d63..788e07e9 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -34,12 +34,17 @@ export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) } +export interface ImageSettings { + autoResize?: boolean; // default: true (resize images to 2000x2000 max for better model compatibility) +} + export interface Settings { lastChangelogVersion?: string; defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; - queueMode?: "all" | "one-at-a-time"; + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; theme?: string; compaction?: CompactionSettings; branchSummary?: BranchSummarySettings; @@ -48,10 +53,10 @@ export interface Settings { shellPath?: string; // Custom shell path (e.g., for Cygwin users on Windows) collapseChangelog?: boolean; // Show condensed changelog after update (use /changelog for full) hooks?: string[]; // Array of hook file paths - hookTimeout?: number; // Timeout for hook execution in ms (default: 30000) customTools?: string[]; // Array of custom tool file paths skills?: SkillsSettings; terminal?: TerminalSettings; + images?: ImageSettings; enabledModels?: string[]; // Model patterns for cycling (same format as --models CLI flag) } @@ -126,13 +131,24 @@ export class SettingsManager { } try { const content = readFileSync(path, "utf-8"); - return JSON.parse(content); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } catch (error) { console.error(`Warning: Could not read settings file ${path}: ${error}`); return {}; } } + /** Migrate old settings format to new format */ + private static migrateSettings(settings: Record): Settings { + // Migrate queueMode -> steeringMode + if ("queueMode" in settings && !("steeringMode" in settings)) { + settings.steeringMode = settings.queueMode; + delete settings.queueMode; + } + return settings as Settings; + } + private loadProjectSettings(): Settings { if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { return {}; @@ -140,7 +156,8 @@ export class SettingsManager { try { const content = readFileSync(this.projectSettingsPath, "utf-8"); - return JSON.parse(content); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } catch (error) { console.error(`Warning: Could not read project settings file: ${error}`); return {}; @@ -205,12 +222,21 @@ export class SettingsManager { this.save(); } - getQueueMode(): "all" | "one-at-a-time" { - return this.settings.queueMode || "one-at-a-time"; + getSteeringMode(): "all" | "one-at-a-time" { + return this.settings.steeringMode || "one-at-a-time"; } - setQueueMode(mode: "all" | "one-at-a-time"): void { - this.globalSettings.queueMode = mode; + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.steeringMode = mode; + this.save(); + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.settings.followUpMode || "one-at-a-time"; + } + + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.followUpMode = mode; this.save(); } @@ -322,15 +348,6 @@ export class SettingsManager { this.save(); } - getHookTimeout(): number { - return this.settings.hookTimeout ?? 30000; - } - - setHookTimeout(timeout: number): void { - this.globalSettings.hookTimeout = timeout; - this.save(); - } - getCustomToolPaths(): string[] { return [...(this.settings.customTools ?? [])]; } @@ -378,6 +395,18 @@ export class SettingsManager { this.save(); } + getImageAutoResize(): boolean { + return this.settings.images?.autoResize ?? true; + } + + setImageAutoResize(enabled: boolean): void { + if (!this.globalSettings.images) { + this.globalSettings.images = {}; + } + this.globalSettings.images.autoResize = enabled; + this.save(); + } + getEnabledModels(): string[] | undefined { return this.settings.enabledModels; } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index 7df751e5..da9b00da 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -5,7 +5,7 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; -import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; +import { getAgentDir, getDocsPath, getExamplesPath, getReadmePath } from "../config.js"; import type { SkillsSettings } from "./settings-manager.js"; import { formatSkillsForPrompt, loadSkills, type Skill } from "./skills.js"; import type { ToolName } from "./tools/index.js"; @@ -202,9 +202,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin return prompt; } - // Get absolute paths to documentation + // Get absolute paths to documentation and examples const readmePath = getReadmePath(); const docsPath = getDocsPath(); + const examplesPath = getExamplesPath(); // Build tools list based on selected tools const tools = selectedTools || (["read", "bash", "edit", "write"] as ToolName[]); @@ -279,7 +280,9 @@ ${guidelines} Documentation: - Main documentation: ${readmePath} - Additional docs: ${docsPath} -- When asked about: custom models/providers (README sufficient), themes (docs/theme.md), skills (docs/skills.md), hooks (docs/hooks.md), custom tools (docs/custom-tools.md), RPC (docs/rpc.md)`; +- Examples: ${examplesPath} (hooks, custom tools, SDK) +- When asked to create: custom models/providers (README.md), hooks (docs/hooks.md, examples/hooks/), custom tools (docs/custom-tools.md, docs/tui.md, examples/custom-tools/), themes (docs/theme.md), skills (docs/skills.md) +- Always read the doc, examples, AND follow .md cross-references before implementing`; if (appendSection) { prompt += appendSection; diff --git a/packages/coding-agent/src/core/tools/edit-diff.ts b/packages/coding-agent/src/core/tools/edit-diff.ts new file mode 100644 index 00000000..a29710c7 --- /dev/null +++ b/packages/coding-agent/src/core/tools/edit-diff.ts @@ -0,0 +1,211 @@ +/** + * Shared diff computation utilities for the edit tool. + * Used by both edit.ts (for execution) and tool-execution.ts (for preview rendering). + */ + +import * as Diff from "diff"; +import { constants } from "fs"; +import { access, readFile } from "fs/promises"; +import { resolveToCwd } from "./path-utils.js"; + +export function detectLineEnding(content: string): "\r\n" | "\n" { + const crlfIdx = content.indexOf("\r\n"); + const lfIdx = content.indexOf("\n"); + if (lfIdx === -1) return "\n"; + if (crlfIdx === -1) return "\n"; + return crlfIdx < lfIdx ? "\r\n" : "\n"; +} + +export function normalizeToLF(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +export function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { + return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; +} + +/** Strip UTF-8 BOM if present, return both the BOM (if any) and the text without it */ +export function stripBom(content: string): { bom: string; text: string } { + return content.startsWith("\uFEFF") ? { bom: "\uFEFF", text: content.slice(1) } : { bom: "", text: content }; +} + +/** + * Generate a unified diff string with line numbers and context. + * Returns both the diff string and the first changed line number (in the new file). + */ +export function generateDiffString( + oldContent: string, + newContent: string, + contextLines = 4, +): { diff: string; firstChangedLine: number | undefined } { + const parts = Diff.diffLines(oldContent, newContent); + const output: string[] = []; + + const oldLines = oldContent.split("\n"); + const newLines = newContent.split("\n"); + const maxLineNum = Math.max(oldLines.length, newLines.length); + const lineNumWidth = String(maxLineNum).length; + + let oldLineNum = 1; + let newLineNum = 1; + let lastWasChange = false; + let firstChangedLine: number | undefined; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const raw = part.value.split("\n"); + if (raw[raw.length - 1] === "") { + raw.pop(); + } + + if (part.added || part.removed) { + // Capture the first changed line (in the new file) + if (firstChangedLine === undefined) { + firstChangedLine = newLineNum; + } + + // Show the change + for (const line of raw) { + if (part.added) { + const lineNum = String(newLineNum).padStart(lineNumWidth, " "); + output.push(`+${lineNum} ${line}`); + newLineNum++; + } else { + // removed + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(`-${lineNum} ${line}`); + oldLineNum++; + } + } + lastWasChange = true; + } else { + // Context lines - only show a few before/after changes + const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); + + if (lastWasChange || nextPartIsChange) { + // Show context + let linesToShow = raw; + let skipStart = 0; + let skipEnd = 0; + + if (!lastWasChange) { + // Show only last N lines as leading context + skipStart = Math.max(0, raw.length - contextLines); + linesToShow = raw.slice(skipStart); + } + + if (!nextPartIsChange && linesToShow.length > contextLines) { + // Show only first N lines as trailing context + skipEnd = linesToShow.length - contextLines; + linesToShow = linesToShow.slice(0, contextLines); + } + + // Add ellipsis if we skipped lines at start + if (skipStart > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + // Update line numbers for the skipped leading context + oldLineNum += skipStart; + newLineNum += skipStart; + } + + for (const line of linesToShow) { + const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); + output.push(` ${lineNum} ${line}`); + oldLineNum++; + newLineNum++; + } + + // Add ellipsis if we skipped lines at end + if (skipEnd > 0) { + output.push(` ${"".padStart(lineNumWidth, " ")} ...`); + // Update line numbers for the skipped trailing context + oldLineNum += skipEnd; + newLineNum += skipEnd; + } + } else { + // Skip these context lines entirely + oldLineNum += raw.length; + newLineNum += raw.length; + } + + lastWasChange = false; + } + } + + return { diff: output.join("\n"), firstChangedLine }; +} + +export interface EditDiffResult { + diff: string; + firstChangedLine: number | undefined; +} + +export interface EditDiffError { + error: string; +} + +/** + * Compute the diff for an edit operation without applying it. + * Used for preview rendering in the TUI before the tool executes. + */ +export async function computeEditDiff( + path: string, + oldText: string, + newText: string, + cwd: string, +): Promise { + const absolutePath = resolveToCwd(path, cwd); + + try { + // Check if file exists and is readable + try { + await access(absolutePath, constants.R_OK); + } catch { + return { error: `File not found: ${path}` }; + } + + // Read the file + const rawContent = await readFile(absolutePath, "utf-8"); + + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { text: content } = stripBom(rawContent); + + const normalizedContent = normalizeToLF(content); + const normalizedOldText = normalizeToLF(oldText); + const normalizedNewText = normalizeToLF(newText); + + // Check if old text exists + if (!normalizedContent.includes(normalizedOldText)) { + return { + error: `Could not find the exact text in ${path}. The old text must match exactly including all whitespace and newlines.`, + }; + } + + // Count occurrences + const occurrences = normalizedContent.split(normalizedOldText).length - 1; + if (occurrences > 1) { + return { + error: `Found ${occurrences} occurrences of the text in ${path}. The text must be unique. Please provide more context to make it unique.`, + }; + } + + // Compute the new content + const index = normalizedContent.indexOf(normalizedOldText); + const normalizedNewContent = + normalizedContent.substring(0, index) + + normalizedNewText + + normalizedContent.substring(index + normalizedOldText.length); + + // Check if it would actually change anything + if (normalizedContent === normalizedNewContent) { + return { + error: `No changes would be made to ${path}. The replacement produces identical content.`, + }; + } + + // Generate the diff + return generateDiffString(normalizedContent, normalizedNewContent); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } +} diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts index a46a209d..3360308c 100644 --- a/packages/coding-agent/src/core/tools/edit.ts +++ b/packages/coding-agent/src/core/tools/edit.ts @@ -1,132 +1,10 @@ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import * as Diff from "diff"; import { constants } from "fs"; import { access, readFile, writeFile } from "fs/promises"; +import { detectLineEnding, generateDiffString, normalizeToLF, restoreLineEndings, stripBom } from "./edit-diff.js"; import { resolveToCwd } from "./path-utils.js"; -function detectLineEnding(content: string): "\r\n" | "\n" { - const crlfIdx = content.indexOf("\r\n"); - const lfIdx = content.indexOf("\n"); - if (lfIdx === -1) return "\n"; - if (crlfIdx === -1) return "\n"; - return crlfIdx < lfIdx ? "\r\n" : "\n"; -} - -function normalizeToLF(text: string): string { - return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); -} - -function restoreLineEndings(text: string, ending: "\r\n" | "\n"): string { - return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text; -} - -/** - * Generate a unified diff string with line numbers and context - * Returns both the diff string and the first changed line number (in the new file) - */ -function generateDiffString( - oldContent: string, - newContent: string, - contextLines = 4, -): { diff: string; firstChangedLine: number | undefined } { - const parts = Diff.diffLines(oldContent, newContent); - const output: string[] = []; - - const oldLines = oldContent.split("\n"); - const newLines = newContent.split("\n"); - const maxLineNum = Math.max(oldLines.length, newLines.length); - const lineNumWidth = String(maxLineNum).length; - - let oldLineNum = 1; - let newLineNum = 1; - let lastWasChange = false; - let firstChangedLine: number | undefined; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const raw = part.value.split("\n"); - if (raw[raw.length - 1] === "") { - raw.pop(); - } - - if (part.added || part.removed) { - // Capture the first changed line (in the new file) - if (firstChangedLine === undefined) { - firstChangedLine = newLineNum; - } - - // Show the change - for (const line of raw) { - if (part.added) { - const lineNum = String(newLineNum).padStart(lineNumWidth, " "); - output.push(`+${lineNum} ${line}`); - newLineNum++; - } else { - // removed - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(`-${lineNum} ${line}`); - oldLineNum++; - } - } - lastWasChange = true; - } else { - // Context lines - only show a few before/after changes - const nextPartIsChange = i < parts.length - 1 && (parts[i + 1].added || parts[i + 1].removed); - - if (lastWasChange || nextPartIsChange) { - // Show context - let linesToShow = raw; - let skipStart = 0; - let skipEnd = 0; - - if (!lastWasChange) { - // Show only last N lines as leading context - skipStart = Math.max(0, raw.length - contextLines); - linesToShow = raw.slice(skipStart); - } - - if (!nextPartIsChange && linesToShow.length > contextLines) { - // Show only first N lines as trailing context - skipEnd = linesToShow.length - contextLines; - linesToShow = linesToShow.slice(0, contextLines); - } - - // Add ellipsis if we skipped lines at start - if (skipStart > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); - // Update line numbers for the skipped leading context - oldLineNum += skipStart; - newLineNum += skipStart; - } - - for (const line of linesToShow) { - const lineNum = String(oldLineNum).padStart(lineNumWidth, " "); - output.push(` ${lineNum} ${line}`); - oldLineNum++; - newLineNum++; - } - - // Add ellipsis if we skipped lines at end - if (skipEnd > 0) { - output.push(` ${"".padStart(lineNumWidth, " ")} ...`); - // Update line numbers for the skipped trailing context - oldLineNum += skipEnd; - newLineNum += skipEnd; - } - } else { - // Skip these context lines entirely - oldLineNum += raw.length; - newLineNum += raw.length; - } - - lastWasChange = false; - } - } - - return { diff: output.join("\n"), firstChangedLine }; -} - const editSchema = Type.Object({ path: Type.String({ description: "Path to the file to edit (relative or absolute)" }), oldText: Type.String({ description: "Exact text to find and replace (must match exactly)" }), @@ -196,13 +74,16 @@ export function createEditTool(cwd: string): AgentTool { } // Read the file - const content = await readFile(absolutePath, "utf-8"); + const rawContent = await readFile(absolutePath, "utf-8"); // Check if aborted after reading if (aborted) { return; } + // Strip BOM before matching (LLM won't include invisible BOM in oldText) + const { bom, text: content } = stripBom(rawContent); + const originalEnding = detectLineEnding(content); const normalizedContent = normalizeToLF(content); const normalizedOldText = normalizeToLF(oldText); @@ -262,7 +143,7 @@ export function createEditTool(cwd: string): AgentTool { return; } - const finalContent = restoreLineEndings(normalizedNewContent, originalEnding); + const finalContent = bom + restoreLineEndings(normalizedNewContent, originalEnding); await writeFile(absolutePath, finalContent, "utf-8"); // Check if aborted after writing diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts index 74701568..908fbf3e 100644 --- a/packages/coding-agent/src/core/tools/index.ts +++ b/packages/coding-agent/src/core/tools/index.ts @@ -3,7 +3,7 @@ export { createEditTool, editTool } from "./edit.js"; export { createFindTool, type FindToolDetails, findTool } from "./find.js"; export { createGrepTool, type GrepToolDetails, grepTool } from "./grep.js"; export { createLsTool, type LsToolDetails, lsTool } from "./ls.js"; -export { createReadTool, type ReadToolDetails, readTool } from "./read.js"; +export { createReadTool, type ReadToolDetails, type ReadToolOptions, readTool } from "./read.js"; export type { TruncationResult } from "./truncate.js"; export { createWriteTool, writeTool } from "./write.js"; @@ -13,7 +13,7 @@ import { createEditTool, editTool } from "./edit.js"; import { createFindTool, findTool } from "./find.js"; import { createGrepTool, grepTool } from "./grep.js"; import { createLsTool, lsTool } from "./ls.js"; -import { createReadTool, readTool } from "./read.js"; +import { createReadTool, type ReadToolOptions, readTool } from "./read.js"; import { createWriteTool, writeTool } from "./write.js"; /** Tool type (AgentTool from pi-ai) */ @@ -38,26 +38,31 @@ export const allTools = { export type ToolName = keyof typeof allTools; +export interface ToolsOptions { + /** Options for the read tool */ + read?: ReadToolOptions; +} + /** * Create coding tools configured for a specific working directory. */ -export function createCodingTools(cwd: string): Tool[] { - return [createReadTool(cwd), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)]; +export function createCodingTools(cwd: string, options?: ToolsOptions): Tool[] { + return [createReadTool(cwd, options?.read), createBashTool(cwd), createEditTool(cwd), createWriteTool(cwd)]; } /** * Create read-only tools configured for a specific working directory. */ -export function createReadOnlyTools(cwd: string): Tool[] { - return [createReadTool(cwd), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)]; +export function createReadOnlyTools(cwd: string, options?: ToolsOptions): Tool[] { + return [createReadTool(cwd, options?.read), createGrepTool(cwd), createFindTool(cwd), createLsTool(cwd)]; } /** * Create all tools configured for a specific working directory. */ -export function createAllTools(cwd: string): Record { +export function createAllTools(cwd: string, options?: ToolsOptions): Record { return { - read: createReadTool(cwd), + read: createReadTool(cwd, options?.read), bash: createBashTool(cwd), edit: createEditTool(cwd), write: createWriteTool(cwd), diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts index da3a62b6..e7ba44fb 100644 --- a/packages/coding-agent/src/core/tools/read.ts +++ b/packages/coding-agent/src/core/tools/read.ts @@ -3,6 +3,7 @@ import type { ImageContent, TextContent } from "@mariozechner/pi-ai"; import { Type } from "@sinclair/typebox"; import { constants } from "fs"; import { access, readFile } from "fs/promises"; +import { formatDimensionNote, resizeImage } from "../../utils/image-resize.js"; import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js"; import { resolveReadPath } from "./path-utils.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, type TruncationResult, truncateHead } from "./truncate.js"; @@ -17,7 +18,13 @@ export interface ReadToolDetails { truncation?: TruncationResult; } -export function createReadTool(cwd: string): AgentTool { +export interface ReadToolOptions { + /** Whether to auto-resize images to 2000x2000 max. Default: true */ + autoResizeImages?: boolean; +} + +export function createReadTool(cwd: string, options?: ReadToolOptions): AgentTool { + const autoResizeImages = options?.autoResizeImages ?? true; return { name: "read", label: "read", @@ -72,10 +79,26 @@ export function createReadTool(cwd: string): AgentTool { const buffer = await readFile(absolutePath); const base64 = buffer.toString("base64"); - content = [ - { type: "text", text: `Read image file [${mimeType}]` }, - { type: "image", data: base64, mimeType }, - ]; + if (autoResizeImages) { + // Resize image if needed + const resized = await resizeImage({ type: "image", data: base64, mimeType }); + const dimensionNote = formatDimensionNote(resized); + + let textNote = `Read image file [${resized.mimeType}]`; + if (dimensionNote) { + textNote += `\n${dimensionNote}`; + } + + content = [ + { type: "text", text: textNote }, + { type: "image", data: resized.data, mimeType: resized.mimeType }, + ]; + } else { + content = [ + { type: "text", text: `Read image file [${mimeType}]` }, + { type: "image", data: base64, mimeType }, + ]; + } } else { // Read as text const textContent = await readFile(absolutePath, "utf-8"); diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 1dae4688..69271da2 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -30,20 +30,22 @@ export { generateSummary, getLastAssistantUsage, prepareBranchEntries, + serializeConversation, shouldCompact, } from "./core/compaction/index.js"; // Custom tools export type { AgentToolUpdateCallback, - CustomAgentTool, + CustomTool, + CustomToolAPI, + CustomToolContext, CustomToolFactory, + CustomToolSessionEvent, CustomToolsLoadResult, + CustomToolUIContext, ExecResult, LoadedCustomTool, RenderResultOptions, - SessionEvent as ToolSessionEvent, - ToolAPI, - ToolUIContext, } from "./core/custom-tools/index.js"; export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index.js"; export type * from "./core/hooks/index.js"; @@ -86,6 +88,10 @@ export { discoverSkills, discoverSlashCommands, type FileSlashCommand, + // Hook types + type HookAPI, + type HookContext, + type HookFactory, loadSettings, // Pre-built tools (use process.cwd()) readOnlyTools, @@ -101,6 +107,7 @@ export { getLatestCompactionEntry, type ModelChangeEntry, migrateSessionEntries, + type NewSessionOptions, parseSessionEntries, type SessionContext, type SessionEntry, @@ -113,6 +120,7 @@ export { } from "./core/session-manager.js"; export { type CompactionSettings, + type ImageSettings, type RetrySettings, type Settings, SettingsManager, @@ -142,11 +150,15 @@ export { type LsToolDetails, lsTool, type ReadToolDetails, + type ReadToolOptions, readTool, + type ToolsOptions, type TruncationResult, writeTool, } from "./core/tools/index.js"; // Main entry point export { main } from "./main.js"; -// Theme utilities for custom tools -export { getMarkdownTheme } from "./modes/interactive/theme/theme.js"; +// UI components for hooks +export { BorderedLoader } from "./modes/interactive/components/bordered-loader.js"; +// Theme utilities for custom tools and hooks +export { getMarkdownTheme, Theme, type ThemeColor } from "./modes/interactive/theme/theme.js"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 9b67d748..fa6974ab 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -17,7 +17,7 @@ import { CONFIG_DIR_NAME, getAgentDir, getModelsPath, VERSION } from "./config.j import type { AgentSession } from "./core/agent-session.js"; import type { LoadedCustomTool } from "./core/custom-tools/index.js"; -import { exportFromFile } from "./core/export-html.js"; +import { exportFromFile } from "./core/export-html/index.js"; import type { HookUIContext } from "./core/index.js"; import type { ModelRegistry } from "./core/model-registry.js"; import { resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; @@ -119,7 +119,10 @@ async function runInteractiveMode( } } -async function prepareInitialMessage(parsed: Args): Promise<{ +async function prepareInitialMessage( + parsed: Args, + autoResizeImages: boolean, +): Promise<{ initialMessage?: string; initialImages?: ImageContent[]; }> { @@ -127,7 +130,7 @@ async function prepareInitialMessage(parsed: Args): Promise<{ return {}; } - const { text, images } = await processFileArguments(parsed.fileArgs); + const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages }); let initialMessage: string; if (parsed.messages.length > 0) { @@ -329,13 +332,12 @@ export async function main(args: string[]) { } const cwd = process.cwd(); - const { initialMessage, initialImages } = await prepareInitialMessage(parsed); + const settingsManager = SettingsManager.create(cwd); + time("SettingsManager.create"); + const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); time("prepareInitialMessage"); const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - - const settingsManager = SettingsManager.create(cwd); - time("SettingsManager.create"); initTheme(settingsManager.getTheme(), isInteractive); time("initTheme"); diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts index 8757e76c..01c919f3 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts @@ -53,16 +53,15 @@ export class AssistantMessageComponent extends Container { if (this.hideThinkingBlock) { // Show static "Thinking..." label when hidden - this.contentContainer.addChild(new Text(theme.fg("muted", "Thinking..."), 1, 0)); + this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", "Thinking...")), 1, 0)); if (hasTextAfter) { this.contentContainer.addChild(new Spacer(1)); } } else { - // Thinking traces in muted color, italic - // Use Markdown component with default text style for consistent styling + // Thinking traces in thinkingText color, italic this.contentContainer.addChild( new Markdown(content.thinking.trim(), 1, 0, getMarkdownTheme(), { - color: (text: string) => theme.fg("muted", text), + color: (text: string) => theme.fg("thinkingText", text), italic: true, }), ); diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts new file mode 100644 index 00000000..e83f32a4 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts @@ -0,0 +1,41 @@ +import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import type { Theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +/** Loader wrapped with borders for hook UI */ +export class BorderedLoader extends Container { + private loader: CancellableLoader; + + constructor(tui: TUI, theme: Theme, message: string) { + super(); + const borderColor = (s: string) => theme.fg("border", s); + this.addChild(new DynamicBorder(borderColor)); + this.loader = new CancellableLoader( + tui, + (s) => theme.fg("accent", s), + (s) => theme.fg("muted", s), + message, + ); + this.addChild(this.loader); + this.addChild(new Spacer(1)); + this.addChild(new Text(theme.fg("muted", "esc cancel"), 1, 0)); + this.addChild(new Spacer(1)); + this.addChild(new DynamicBorder(borderColor)); + } + + get signal(): AbortSignal { + return this.loader.signal; + } + + set onAbort(fn: (() => void) | undefined) { + this.loader.onAbort = fn; + } + + handleInput(data: string): void { + this.loader.handleInput(data); + } + + dispose(): void { + this.loader.dispose(); + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 8a75f0d9..c0b951bd 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,5 +1,6 @@ import { Editor, + isAltEnter, isCtrlC, isCtrlD, isCtrlG, @@ -28,8 +29,14 @@ export class CustomEditor extends Editor { public onCtrlT?: () => void; public onCtrlG?: () => void; public onCtrlZ?: () => void; + public onAltEnter?: () => void; handleInput(data: string): void { + // Intercept Alt+Enter for follow-up messages + if (isAltEnter(data) && this.onAltEnter) { + this.onAltEnter(); + return; + } // Intercept Ctrl+G for external editor if (isCtrlG(data) && this.onCtrlG) { this.onCtrlG(); diff --git a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts index b835ae0a..2ee05ccb 100644 --- a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts +++ b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts @@ -2,7 +2,11 @@ import type { Component } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; /** - * Dynamic border component that adjusts to viewport width + * Dynamic border component that adjusts to viewport width. + * + * Note: When used from hooks loaded via jiti, the global `theme` may be undefined + * because jiti creates a separate module cache. Always pass an explicit color + * function when using DynamicBorder in components exported for hook use. */ export class DynamicBorder implements Component { private color: (str: string) => string; diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts index 05e7a766..78db717c 100644 --- a/packages/coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/coding-agent/src/modes/interactive/components/footer.ts @@ -1,11 +1,22 @@ -import type { AgentState } from "@mariozechner/pi-agent-core"; import type { AssistantMessage } from "@mariozechner/pi-ai"; -import { type Component, visibleWidth } from "@mariozechner/pi-tui"; +import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { existsSync, type FSWatcher, readFileSync, watch } from "fs"; import { dirname, join } from "path"; -import type { ModelRegistry } from "../../../core/model-registry.js"; +import type { AgentSession } from "../../../core/agent-session.js"; import { theme } from "../theme/theme.js"; +/** + * Sanitize text for display in a single-line status. + * Removes newlines, tabs, carriage returns, and other control characters. + */ +function sanitizeStatusText(text: string): string { + // Replace newlines, tabs, carriage returns with space, then collapse multiple spaces + return text + .replace(/[\r\n\t]/g, " ") + .replace(/ +/g, " ") + .trim(); +} + /** * Find the git root directory by walking up from cwd. * Returns the path to .git/HEAD if found, null otherwise. @@ -30,22 +41,36 @@ function findGitHeadPath(): string | null { * Footer component that shows pwd, token stats, and context usage */ export class FooterComponent implements Component { - private state: AgentState; - private modelRegistry: ModelRegistry; + private session: AgentSession; private cachedBranch: string | null | undefined = undefined; // undefined = not checked yet, null = not in git repo, string = branch name private gitWatcher: FSWatcher | null = null; private onBranchChange: (() => void) | null = null; private autoCompactEnabled: boolean = true; + private hookStatuses: Map = new Map(); - constructor(state: AgentState, modelRegistry: ModelRegistry) { - this.state = state; - this.modelRegistry = modelRegistry; + constructor(session: AgentSession) { + this.session = session; } setAutoCompactEnabled(enabled: boolean): void { this.autoCompactEnabled = enabled; } + /** + * Set hook status text to display in the footer. + * Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width. + * ANSI escape codes for styling are preserved. + * @param key - Unique key to identify this status + * @param text - Status text, or undefined to clear + */ + setHookStatus(key: string, text: string | undefined): void { + if (text === undefined) { + this.hookStatuses.delete(key); + } else { + this.hookStatuses.set(key, text); + } + } + /** * Set up a file watcher on .git/HEAD to detect branch changes. * Call the provided callback when branch changes. @@ -89,10 +114,6 @@ export class FooterComponent implements Component { } } - updateState(state: AgentState): void { - this.state = state; - } - invalidate(): void { // Invalidate cached branch so it gets re-read on next render this.cachedBranch = undefined; @@ -132,26 +153,27 @@ export class FooterComponent implements Component { } render(width: number): string[] { - // Calculate cumulative usage from all assistant messages + const state = this.session.state; + + // Calculate cumulative usage from ALL session entries (not just post-compaction messages) let totalInput = 0; let totalOutput = 0; let totalCacheRead = 0; let totalCacheWrite = 0; let totalCost = 0; - for (const message of this.state.messages) { - if (message.role === "assistant") { - const assistantMsg = message as AssistantMessage; - totalInput += assistantMsg.usage.input; - totalOutput += assistantMsg.usage.output; - totalCacheRead += assistantMsg.usage.cacheRead; - totalCacheWrite += assistantMsg.usage.cacheWrite; - totalCost += assistantMsg.usage.cost.total; + for (const entry of this.session.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + totalInput += entry.message.usage.input; + totalOutput += entry.message.usage.output; + totalCacheRead += entry.message.usage.cacheRead; + totalCacheWrite += entry.message.usage.cacheWrite; + totalCost += entry.message.usage.cost.total; } } // Get last assistant message for context percentage calculation (skip aborted messages) - const lastAssistantMessage = this.state.messages + const lastAssistantMessage = state.messages .slice() .reverse() .find((m) => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; @@ -163,7 +185,7 @@ export class FooterComponent implements Component { lastAssistantMessage.usage.cacheRead + lastAssistantMessage.usage.cacheWrite : 0; - const contextWindow = this.state.model?.contextWindow || 0; + const contextWindow = state.model?.contextWindow || 0; const contextPercentValue = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; const contextPercent = contextPercentValue.toFixed(1); @@ -209,7 +231,7 @@ export class FooterComponent implements Component { if (totalCacheWrite) statsParts.push(`W${formatTokens(totalCacheWrite)}`); // Show cost with "(sub)" indicator if using OAuth subscription - const usingSubscription = this.state.model ? this.modelRegistry.isUsingOAuth(this.state.model) : false; + const usingSubscription = state.model ? this.session.modelRegistry.isUsingOAuth(state.model) : false; if (totalCost || usingSubscription) { const costStr = `$${totalCost.toFixed(3)}${usingSubscription ? " (sub)" : ""}`; statsParts.push(costStr); @@ -231,12 +253,12 @@ export class FooterComponent implements Component { let statsLeft = statsParts.join(" "); // Add model name on the right side, plus thinking level if model supports it - const modelName = this.state.model?.id || "no-model"; + const modelName = state.model?.id || "no-model"; // Add thinking level hint if model supports reasoning and thinking is enabled let rightSide = modelName; - if (this.state.model?.reasoning) { - const thinkingLevel = this.state.thinkingLevel || "off"; + if (state.model?.reasoning) { + const thinkingLevel = state.thinkingLevel || "off"; if (thinkingLevel !== "off") { rightSide = `${modelName} โ€ข ${thinkingLevel}`; } @@ -285,6 +307,18 @@ export class FooterComponent implements Component { const remainder = statsLine.slice(statsLeft.length); // padding + rightSide const dimRemainder = theme.fg("dim", remainder); - return [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; + const lines = [theme.fg("dim", pwd), dimStatsLeft + dimRemainder]; + + // Add hook statuses on a single line, sorted by key alphabetically + if (this.hookStatuses.size > 0) { + const sortedStatuses = Array.from(this.hookStatuses.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, text]) => sanitizeStatusText(text)); + const statusLine = sortedStatuses.join(" "); + // Truncate to terminal width with dim ellipsis for consistency with footer style + lines.push(truncateToWidth(statusLine, width, theme.fg("dim", "..."))); + } + + return lines; } } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts new file mode 100644 index 00000000..3b1282b4 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts @@ -0,0 +1,118 @@ +/** + * Multi-line editor component for hooks. + * Supports Ctrl+G for external editor. + */ + +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Container, Editor, isCtrlC, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui"; +import { getEditorTheme, theme } from "../theme/theme.js"; +import { DynamicBorder } from "./dynamic-border.js"; + +export class HookEditorComponent extends Container { + private editor: Editor; + private onSubmitCallback: (value: string) => void; + private onCancelCallback: () => void; + private tui: TUI; + + constructor( + tui: TUI, + title: string, + prefill: string | undefined, + onSubmit: (value: string) => void, + onCancel: () => void, + ) { + super(); + + this.tui = tui; + this.onSubmitCallback = onSubmit; + this.onCancelCallback = onCancel; + + // Add top border + this.addChild(new DynamicBorder()); + this.addChild(new Spacer(1)); + + // Add title + this.addChild(new Text(theme.fg("accent", title), 1, 0)); + this.addChild(new Spacer(1)); + + // Create editor + this.editor = new Editor(getEditorTheme()); + if (prefill) { + this.editor.setText(prefill); + } + this.addChild(this.editor); + + this.addChild(new Spacer(1)); + + // Add hint + const hasExternalEditor = !!(process.env.VISUAL || process.env.EDITOR); + const hint = hasExternalEditor + ? "ctrl+enter submit esc cancel ctrl+g external editor" + : "ctrl+enter submit esc cancel"; + this.addChild(new Text(theme.fg("dim", hint), 1, 0)); + + this.addChild(new Spacer(1)); + + // Add bottom border + this.addChild(new DynamicBorder()); + } + + handleInput(keyData: string): void { + // Ctrl+Enter to submit + if (keyData === "\x1b[13;5u" || keyData === "\x1b[27;5;13~") { + this.onSubmitCallback(this.editor.getText()); + return; + } + + // Escape or Ctrl+C to cancel + if (isEscape(keyData) || isCtrlC(keyData)) { + this.onCancelCallback(); + return; + } + + // Ctrl+G for external editor + if (isCtrlG(keyData)) { + this.openExternalEditor(); + return; + } + + // Forward to editor + this.editor.handleInput(keyData); + } + + private openExternalEditor(): void { + const editorCmd = process.env.VISUAL || process.env.EDITOR; + if (!editorCmd) { + return; + } + + const currentText = this.editor.getText(); + const tmpFile = path.join(os.tmpdir(), `pi-hook-editor-${Date.now()}.md`); + + try { + fs.writeFileSync(tmpFile, currentText, "utf-8"); + this.tui.stop(); + + const [editor, ...editorArgs] = editorCmd.split(" "); + const result = spawnSync(editor, [...editorArgs, tmpFile], { + stdio: "inherit", + }); + + if (result.status === 0) { + const newContent = fs.readFileSync(tmpFile, "utf-8").replace(/\n$/, ""); + this.editor.setText(newContent); + } + } finally { + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + this.tui.start(); + this.tui.requestRender(); + } + } +} diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/hook-input.ts index a7b30ba0..e76c41e5 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-input.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-input.ts @@ -2,7 +2,7 @@ * Simple text input component for hooks. */ -import { Container, Input, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; +import { Container, Input, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -52,8 +52,8 @@ export class HookInputComponent extends Container { return; } - // Escape to cancel - if (isEscape(keyData)) { + // Escape or Ctrl+C to cancel + if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); return; } diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts index e39a9119..2238c79f 100644 --- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts @@ -3,7 +3,7 @@ * Displays a list of string options with keyboard navigation. */ -import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; +import { Container, isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -83,8 +83,8 @@ export class HookSelectorComponent extends Container { this.onSelectCallback(selected); } } - // Escape - else if (isEscape(keyData)) { + // Escape or Ctrl+C + else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts index bbf77f2a..99429220 100644 --- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts @@ -4,6 +4,7 @@ import { Input, isArrowDown, isArrowUp, + isCtrlC, isEnter, isEscape, Spacer, @@ -152,6 +153,7 @@ export class ModelSelectorComponent extends Container { this.allModels = models; this.filteredModels = models; + this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, models.length - 1)); } private filterModels(query: string): void { @@ -216,11 +218,13 @@ export class ModelSelectorComponent extends Container { handleInput(keyData: string): void { // Up arrow - wrap to bottom when at top if (isArrowUp(keyData)) { + if (this.filteredModels.length === 0) return; this.selectedIndex = this.selectedIndex === 0 ? this.filteredModels.length - 1 : this.selectedIndex - 1; this.updateList(); } // Down arrow - wrap to top when at bottom else if (isArrowDown(keyData)) { + if (this.filteredModels.length === 0) return; this.selectedIndex = this.selectedIndex === this.filteredModels.length - 1 ? 0 : this.selectedIndex + 1; this.updateList(); } @@ -231,8 +235,8 @@ export class ModelSelectorComponent extends Container { this.handleSelect(selectedModel.model); } } - // Escape - else if (isEscape(keyData)) { + // Escape or Ctrl+C + else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } // Pass everything else to search input diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 1a5a480b..ac97c211 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -1,5 +1,14 @@ import { getOAuthProviders, type OAuthProviderInfo } from "@mariozechner/pi-ai"; -import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@mariozechner/pi-tui"; +import { + Container, + isArrowDown, + isArrowUp, + isCtrlC, + isEnter, + isEscape, + Spacer, + TruncatedText, +} from "@mariozechner/pi-tui"; import type { AuthStorage } from "../../../core/auth-storage.js"; import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; @@ -112,8 +121,8 @@ export class OAuthSelectorComponent extends Container { this.onSelectCallback(selectedProvider.id); } } - // Escape - else if (isEscape(keyData)) { + // Escape or Ctrl+C + else if (isEscape(keyData) || isCtrlC(keyData)) { this.onCancelCallback(); } } diff --git a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts deleted file mode 100644 index cebd1e5b..00000000 --- a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; -import { getSelectListTheme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; - -/** - * Component that renders a queue mode selector with borders - */ -export class QueueModeSelectorComponent extends Container { - private selectList: SelectList; - - constructor( - currentMode: "all" | "one-at-a-time", - onSelect: (mode: "all" | "one-at-a-time") => void, - onCancel: () => void, - ) { - super(); - - const queueModes: SelectItem[] = [ - { - value: "one-at-a-time", - label: "one-at-a-time", - description: "Process queued messages one by one (recommended)", - }, - { value: "all", label: "all", description: "Process all queued messages at once" }, - ]; - - // Add top border - this.addChild(new DynamicBorder()); - - // Create selector - this.selectList = new SelectList(queueModes, 2, getSelectListTheme()); - - // Preselect current mode - const currentIndex = queueModes.findIndex((item) => item.value === currentMode); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value as "all" | "one-at-a-time"); - }; - - this.selectList.onCancel = () => { - onCancel(); - }; - - this.addChild(this.selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); - } - - getSelectList(): SelectList { - return this.selectList; - } -} diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1202e3ee..e21ef91e 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -24,7 +24,9 @@ const THINKING_DESCRIPTIONS: Record = { export interface SettingsConfig { autoCompact: boolean; showImages: boolean; - queueMode: "all" | "one-at-a-time"; + autoResizeImages: boolean; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; @@ -36,7 +38,9 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; onShowImagesChange: (enabled: boolean) => void; - onQueueModeChange: (mode: "all" | "one-at-a-time") => void; + onAutoResizeImagesChange: (enabled: boolean) => void; + onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; + onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; @@ -127,10 +131,19 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }, { - id: "queue-mode", - label: "Queue mode", - description: "How to process queued messages while agent is working", - currentValue: config.queueMode, + id: "steering-mode", + label: "Steering mode", + description: + "Enter while streaming queues steering messages. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", + currentValue: config.steeringMode, + values: ["one-at-a-time", "all"], + }, + { + id: "follow-up-mode", + label: "Follow-up mode", + description: + "Alt+Enter queues follow-up messages until agent stops. 'one-at-a-time': deliver one, wait for response. 'all': deliver all at once.", + currentValue: config.followUpMode, values: ["one-at-a-time", "all"], }, { @@ -212,6 +225,15 @@ export class SettingsSelectorComponent extends Container { }); } + // Image auto-resize toggle (always available, affects both attached and read images) + items.splice(supportsImages ? 2 : 1, 0, { + id: "auto-resize-images", + label: "Auto-resize images", + description: "Resize large images to 2000x2000 max for better model compatibility", + currentValue: config.autoResizeImages ? "true" : "false", + values: ["true", "false"], + }); + // Add borders this.addChild(new DynamicBorder()); @@ -227,8 +249,14 @@ export class SettingsSelectorComponent extends Container { case "show-images": callbacks.onShowImagesChange(newValue === "true"); break; - case "queue-mode": - callbacks.onQueueModeChange(newValue as "all" | "one-at-a-time"); + case "auto-resize-images": + callbacks.onAutoResizeImagesChange(newValue === "true"); + break; + case "steering-mode": + callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); + break; + case "follow-up-mode": + callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time"); break; case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts index 7124c84b..5e77e974 100644 --- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts @@ -11,8 +11,10 @@ import { type TUI, } from "@mariozechner/pi-tui"; import stripAnsi from "strip-ansi"; -import type { CustomAgentTool } from "../../../core/custom-tools/types.js"; +import type { CustomTool } from "../../../core/custom-tools/types.js"; +import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js"; import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "../../../core/tools/truncate.js"; +import { sanitizeBinaryOutput } from "../../../utils/shell.js"; import { getLanguageFromPath, highlightCode, theme } from "../theme/theme.js"; import { renderDiff } from "./diff.js"; import { truncateToVisualLines } from "./visual-truncate.js"; @@ -55,20 +57,25 @@ export class ToolExecutionComponent extends Container { private expanded = false; private showImages: boolean; private isPartial = true; - private customTool?: CustomAgentTool; + private customTool?: CustomTool; private ui: TUI; + private cwd: string; private result?: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; isError: boolean; details?: any; }; + // Cached edit diff preview (computed when args arrive, before tool executes) + private editDiffPreview?: EditDiffResult | EditDiffError; + private editDiffArgsKey?: string; // Track which args the preview is for constructor( toolName: string, args: any, options: ToolExecutionOptions = {}, - customTool: CustomAgentTool | undefined, + customTool: CustomTool | undefined, ui: TUI, + cwd: string = process.cwd(), ) { super(); this.toolName = toolName; @@ -76,6 +83,7 @@ export class ToolExecutionComponent extends Container { this.showImages = options.showImages ?? true; this.customTool = customTool; this.ui = ui; + this.cwd = cwd; this.addChild(new Spacer(1)); @@ -97,6 +105,47 @@ export class ToolExecutionComponent extends Container { this.updateDisplay(); } + /** + * Signal that args are complete (tool is about to execute). + * This triggers diff computation for edit tool. + */ + setArgsComplete(): void { + this.maybeComputeEditDiff(); + } + + /** + * Compute edit diff preview when we have complete args. + * This runs async and updates display when done. + */ + private maybeComputeEditDiff(): void { + if (this.toolName !== "edit") return; + + const path = this.args?.path; + const oldText = this.args?.oldText; + const newText = this.args?.newText; + + // Need all three params to compute diff + if (!path || oldText === undefined || newText === undefined) return; + + // Create a key to track which args this computation is for + const argsKey = JSON.stringify({ path, oldText, newText }); + + // Skip if we already computed for these exact args + if (this.editDiffArgsKey === argsKey) return; + + this.editDiffArgsKey = argsKey; + + // Compute diff async + computeEditDiff(path, oldText, newText, this.cwd).then((result) => { + // Only update if args haven't changed since we started + if (this.editDiffArgsKey === argsKey) { + this.editDiffPreview = result; + this.updateDisplay(); + this.ui.requestRender(); + } + }); + } + updateResult( result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; @@ -295,10 +344,8 @@ export class ToolExecutionComponent extends Container { let output = textBlocks .map((c: any) => { - let text = stripAnsi(c.text || "").replace(/\r/g, ""); - text = text.replace(/\x1b./g, ""); - text = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ""); - return text; + // Use sanitizeBinaryOutput to handle binary data that crashes string-width + return sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, ""); }) .join("\n"); @@ -394,9 +441,6 @@ export class ToolExecutionComponent extends Container { theme.fg("toolTitle", theme.bold("write")) + " " + (path ? theme.fg("accent", path) : theme.fg("toolOutput", "...")); - if (totalLines > 10) { - text += ` (${totalLines} lines)`; - } if (fileContent) { const maxLines = this.expanded ? lines.length : 10; @@ -409,29 +453,38 @@ export class ToolExecutionComponent extends Container { .map((line: string) => (lang ? replaceTabs(line) : theme.fg("toolOutput", replaceTabs(line)))) .join("\n"); if (remaining > 0) { - text += theme.fg("toolOutput", `\n... (${remaining} more lines)`); + text += theme.fg("toolOutput", `\n... (${remaining} more lines, ${totalLines} total)`); } } } else if (this.toolName === "edit") { const rawPath = this.args?.file_path || this.args?.path || ""; const path = shortenPath(rawPath); - // Build path display, appending :line if we have a successful result with line info + // Build path display, appending :line if we have diff info let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "..."); - if (this.result && !this.result.isError && this.result.details?.firstChangedLine) { - pathDisplay += theme.fg("warning", `:${this.result.details.firstChangedLine}`); + const firstChangedLine = + (this.editDiffPreview && "firstChangedLine" in this.editDiffPreview + ? this.editDiffPreview.firstChangedLine + : undefined) || + (this.result && !this.result.isError ? this.result.details?.firstChangedLine : undefined); + if (firstChangedLine) { + pathDisplay += theme.fg("warning", `:${firstChangedLine}`); } text = `${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`; - if (this.result) { - if (this.result.isError) { - const errorText = this.getTextOutput(); - if (errorText) { - text += `\n\n${theme.fg("error", errorText)}`; - } - } else if (this.result.details?.diff) { - text += `\n\n${renderDiff(this.result.details.diff, { filePath: rawPath })}`; + if (this.result?.isError) { + // Show error from result + const errorText = this.getTextOutput(); + if (errorText) { + text += `\n\n${theme.fg("error", errorText)}`; + } + } else if (this.editDiffPreview) { + // Use cached diff preview (works both before and after execution) + if ("error" in this.editDiffPreview) { + text += `\n\n${theme.fg("error", this.editDiffPreview.error)}`; + } else if (this.editDiffPreview.diff) { + text += `\n\n${renderDiff(this.editDiffPreview.diff, { filePath: rawPath })}`; } } } else if (this.toolName === "ls") { diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts index 18cd769c..8a8f2152 100644 --- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts @@ -14,7 +14,7 @@ import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; interface UserMessageItem { - index: number; // Index in the full messages array + id: string; // Entry ID in the session text: string; // The message text timestamp?: string; // Optional timestamp if available } @@ -25,7 +25,7 @@ interface UserMessageItem { class UserMessageList implements Component { private messages: UserMessageItem[] = []; private selectedIndex: number = 0; - public onSelect?: (messageIndex: number) => void; + public onSelect?: (entryId: string) => void; public onCancel?: () => void; private maxVisible: number = 10; // Max messages visible @@ -101,7 +101,7 @@ class UserMessageList implements Component { else if (isEnter(keyData)) { const selected = this.messages[this.selectedIndex]; if (selected && this.onSelect) { - this.onSelect(selected.index); + this.onSelect(selected.id); } } // Escape - cancel @@ -125,7 +125,7 @@ class UserMessageList implements Component { export class UserMessageSelectorComponent extends Container { private messageList: UserMessageList; - constructor(messages: UserMessageItem[], onSelect: (messageIndex: number) => void, onCancel: () => void) { + constructor(messages: UserMessageItem[], onSelect: (entryId: string) => void, onCancel: () => void) { super(); // Add header diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b6ea952d..10224016 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6,7 +6,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { AgentMessage, AgentState } from "@mariozechner/pi-agent-core"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, Message, OAuthProvider } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { @@ -23,10 +23,10 @@ import { TUI, visibleWidth, } from "@mariozechner/pi-tui"; -import { exec, spawnSync } from "child_process"; +import { exec, spawn, spawnSync } from "child_process"; import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js"; import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js"; -import type { LoadedCustomTool, SessionEvent as ToolSessionEvent } from "../../core/custom-tools/index.js"; +import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js"; import type { HookUIContext } from "../../core/hooks/index.js"; import { createCompactionSummaryMessage } from "../../core/messages.js"; import { type SessionContext, SessionManager } from "../../core/session-manager.js"; @@ -38,11 +38,13 @@ import { copyToClipboard } from "../../utils/clipboard.js"; import { ArminComponent } from "./components/armin.js"; import { AssistantMessageComponent } from "./components/assistant-message.js"; import { BashExecutionComponent } from "./components/bash-execution.js"; +import { BorderedLoader } from "./components/bordered-loader.js"; import { BranchSummaryMessageComponent } from "./components/branch-summary-message.js"; import { CompactionSummaryMessageComponent } from "./components/compaction-summary-message.js"; import { CustomEditor } from "./components/custom-editor.js"; import { DynamicBorder } from "./components/dynamic-border.js"; import { FooterComponent } from "./components/footer.js"; +import { HookEditorComponent } from "./components/hook-editor.js"; import { HookInputComponent } from "./components/hook-input.js"; import { HookMessageComponent } from "./components/hook-message.js"; import { HookSelectorComponent } from "./components/hook-selector.js"; @@ -54,7 +56,15 @@ import { ToolExecutionComponent } from "./components/tool-execution.js"; import { TreeSelectorComponent } from "./components/tree-selector.js"; import { UserMessageComponent } from "./components/user-message.js"; import { UserMessageSelectorComponent } from "./components/user-message-selector.js"; -import { getAvailableThemes, getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from "./theme/theme.js"; +import { + getAvailableThemes, + getEditorTheme, + getMarkdownTheme, + onThemeChange, + setTheme, + type Theme, + theme, +} from "./theme/theme.js"; /** Interface for components that can be expanded/collapsed */ interface Expandable { @@ -83,8 +93,13 @@ export class InteractiveMode { private lastEscapeTime = 0; private changelogMarkdown: string | undefined = undefined; + // Status line tracking (for mutating immediately-sequential status updates) + private lastStatusSpacer: Spacer | undefined = undefined; + private lastStatusText: Text | undefined = undefined; + // Streaming message tracking private streamingComponent: AssistantMessageComponent | undefined = undefined; + private streamingMessage: AssistantMessage | undefined = undefined; // Tool execution tracking: toolCallId -> component private pendingTools = new Map(); @@ -118,6 +133,7 @@ export class InteractiveMode { // Hook UI state private hookSelector: HookSelectorComponent | undefined = undefined; private hookInput: HookInputComponent | undefined = undefined; + private hookEditor: HookEditorComponent | undefined = undefined; // Custom tools for custom rendering private customTools: Map; @@ -152,7 +168,7 @@ export class InteractiveMode { this.editor = new CustomEditor(getEditorTheme()); this.editorContainer = new Container(); this.editorContainer.addChild(this.editor); - this.footer = new FooterComponent(session.state, session.modelRegistry); + this.footer = new FooterComponent(session); this.footer.setAutoCompactEnabled(session.autoCompactionEnabled); // Define slash commands for autocomplete @@ -160,6 +176,7 @@ export class InteractiveMode { { name: "settings", description: "Open settings menu" }, { name: "model", description: "Select model (opens selector UI)" }, { name: "export", description: "Export session to HTML file" }, + { name: "share", description: "Share session as a secret GitHub gist" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "session", description: "Show session info and stats" }, { name: "changelog", description: "Show changelog entries" }, @@ -245,6 +262,9 @@ export class InteractiveMode { theme.fg("dim", "!") + theme.fg("muted", " to run bash") + "\n" + + theme.fg("dim", "alt+enter") + + theme.fg("muted", " to queue follow-up") + + "\n" + theme.fg("dim", "drop files") + theme.fg("muted", " to attach"); const header = new Text(`${logo}\n${instructions}`, 1, 0); @@ -286,6 +306,10 @@ export class InteractiveMode { this.ui.start(); this.isInitialized = true; + // Set terminal title + const cwdBasename = path.basename(process.cwd()); + this.ui.terminal.setTitle(`pi - ${cwdBasename}`); + // Initialize hooks with TUI-based UI context await this.initHooksAndCustomTools(); @@ -350,19 +374,27 @@ export class InteractiveMode { this.chatContainer.addChild(new Spacer(1)); } - // Load session entries if any - const entries = this.session.sessionManager.getEntries(); - - // Set TUI-based UI context for custom tools - const uiContext = this.createHookUIContext(); + // Create and set hook & tool UI context + const uiContext: HookUIContext = { + select: (title, options) => this.showHookSelector(title, options), + confirm: (title, message) => this.showHookConfirm(title, message), + input: (title, placeholder) => this.showHookInput(title, placeholder), + notify: (message, type) => this.showHookNotify(message, type), + setStatus: (key, text) => this.setHookStatus(key, text), + custom: (factory) => this.showHookCustom(factory), + setEditorText: (text) => this.editor.setText(text), + getEditorText: () => this.editor.getText(), + editor: (title, prefill) => this.showHookEditor(title, prefill), + get theme() { + return theme; + }, + }; this.setToolUIContext(uiContext, true); // Notify custom tools of session start - await this.emitToolSessionEvent({ - entries, - sessionFile: this.session.sessionFile, - previousSessionFile: undefined, + await this.emitCustomToolSessionEvent({ reason: "start", + previousSessionFile: undefined, }); const hookRunner = this.session.hookRunner; @@ -370,34 +402,103 @@ export class InteractiveMode { return; // No hooks loaded } - // Set UI context on hook runner - hookRunner.setUIContext(uiContext, true); + hookRunner.initialize({ + getModel: () => this.session.model, + sendMessageHandler: (message, options) => { + const wasStreaming = this.session.isStreaming; + this.session + .sendHookMessage(message, options) + .then(() => { + // For non-streaming cases with display=true, update UI + // (streaming cases update via message_end event) + if (!wasStreaming && message.display) { + this.rebuildChatFromMessages(); + } + }) + .catch((err) => { + this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, + appendEntryHandler: (customType, data) => { + this.sessionManager.appendCustomEntry(customType, data); + }, + newSessionHandler: async (options) => { + // Stop any loading animation + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + + // Create new session + const success = await this.session.newSession({ parentSession: options?.parentSession }); + if (!success) { + return { cancelled: true }; + } + + // Call setup callback if provided + if (options?.setup) { + await options.setup(this.sessionManager); + } + + // Clear UI state + this.chatContainer.clear(); + this.pendingMessagesContainer.clear(); + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(`${theme.fg("accent", "โœ“ New session started")}`, 1, 1)); + this.ui.requestRender(); + + return { cancelled: false }; + }, + branchHandler: async (entryId) => { + const result = await this.session.branch(entryId); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + this.editor.setText(result.selectedText); + this.showStatus("Branched to new session"); + + return { cancelled: false }; + }, + navigateTreeHandler: async (targetId, options) => { + const result = await this.session.navigateTree(targetId, { summarize: options?.summarize }); + if (result.cancelled) { + return { cancelled: true }; + } + + // Update UI + this.chatContainer.clear(); + this.renderInitialMessages(); + if (result.editorText) { + this.editor.setText(result.editorText); + } + this.showStatus("Navigated to selected point"); + + return { cancelled: false }; + }, + isIdle: () => !this.session.isStreaming, + waitForIdle: () => this.session.agent.waitForIdle(), + abort: () => { + this.session.abort(); + }, + hasPendingMessages: () => this.session.pendingMessageCount > 0, + uiContext, + hasUI: true, + }); // Subscribe to hook errors hookRunner.onError((error) => { this.showHookError(error.hookPath, error.error); }); - // Set up handlers for pi.sendMessage() and pi.appendEntry() - hookRunner.setSendMessageHandler((message, triggerTurn) => { - const wasStreaming = this.session.isStreaming; - this.session - .sendHookMessage(message, triggerTurn) - .then(() => { - // For non-streaming cases with display=true, update UI - // (streaming cases update via message_end event) - if (!wasStreaming && message.display) { - this.rebuildChatFromMessages(); - } - }) - .catch((err) => { - this.showError(`Hook sendMessage failed: ${err instanceof Error ? err.message : String(err)}`); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - this.sessionManager.appendCustomEntry(customType, data); - }); - // Show loaded hooks const hookPaths = hookRunner.getHookPaths(); if (hookPaths.length > 0) { @@ -415,11 +516,20 @@ export class InteractiveMode { /** * Emit session event to all custom tools. */ - private async emitToolSessionEvent(event: ToolSessionEvent): Promise { + private async emitCustomToolSessionEvent(event: CustomToolSessionEvent): Promise { for (const { tool } of this.customTools.values()) { if (tool.onSession) { try { - await tool.onSession(event); + await tool.onSession(event, { + sessionManager: this.session.sessionManager, + modelRegistry: this.session.modelRegistry, + model: this.session.model, + isIdle: () => !this.session.isStreaming, + hasPendingMessages: () => this.session.pendingMessageCount > 0, + abort: () => { + this.session.abort(); + }, + }); } catch (err) { this.showToolError(tool.name, err instanceof Error ? err.message : String(err)); } @@ -437,16 +547,11 @@ export class InteractiveMode { } /** - * Create the UI context for hooks. + * Set hook status text in the footer. */ - private createHookUIContext(): HookUIContext { - return { - select: (title, options) => this.showHookSelector(title, options), - confirm: (title, message) => this.showHookConfirm(title, message), - input: (title, placeholder) => this.showHookInput(title, placeholder), - notify: (message, type) => this.showHookNotify(message, type), - custom: (component) => this.showHookCustom(component), - }; + private setHookStatus(key: string, text: string | undefined): void { + this.footer.setHookStatus(key, text); + this.ui.requestRender(); } /** @@ -529,6 +634,43 @@ export class InteractiveMode { this.ui.requestRender(); } + /** + * Show a multi-line editor for hooks (with Ctrl+G support). + */ + private showHookEditor(title: string, prefill?: string): Promise { + return new Promise((resolve) => { + this.hookEditor = new HookEditorComponent( + this.ui, + title, + prefill, + (value) => { + this.hideHookEditor(); + resolve(value); + }, + () => { + this.hideHookEditor(); + resolve(undefined); + }, + ); + + this.editorContainer.clear(); + this.editorContainer.addChild(this.hookEditor); + this.ui.setFocus(this.hookEditor); + this.ui.requestRender(); + }); + } + + /** + * Hide the hook editor. + */ + private hideHookEditor(): void { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.hookEditor = undefined; + this.ui.setFocus(this.editor); + this.ui.requestRender(); + } + /** * Show a notification for hooks. */ @@ -544,38 +686,37 @@ export class InteractiveMode { /** * Show a custom component with keyboard focus. - * Returns a function to call when done. */ - private showHookCustom(component: Component & { dispose?(): void }): { - close: () => void; - requestRender: () => void; - } { - // Store current editor content + private async showHookCustom( + factory: ( + tui: TUI, + theme: Theme, + done: (result: T) => void, + ) => (Component & { dispose?(): void }) | Promise, + ): Promise { const savedText = this.editor.getText(); - // Replace editor with custom component - this.editorContainer.clear(); - this.editorContainer.addChild(component); - this.ui.setFocus(component); - this.ui.requestRender(); + return new Promise((resolve) => { + let component: Component & { dispose?(): void }; - // Return control object - return { - close: () => { - // Call dispose if available + const close = (result: T) => { component.dispose?.(); - - // Restore editor this.editorContainer.clear(); this.editorContainer.addChild(this.editor); this.editor.setText(savedText); this.ui.setFocus(this.editor); this.ui.requestRender(); - }, - requestRender: () => { + resolve(result); + }; + + Promise.resolve(factory(this.ui, theme, close)).then((c) => { + component = c; + this.editorContainer.clear(); + this.editorContainer.addChild(component); + this.ui.setFocus(component); this.ui.requestRender(); - }, - }; + }); + }); } /** @@ -599,8 +740,9 @@ export class InteractiveMode { this.editor.onEscape = () => { if (this.loadingAnimation) { // Abort and restore queued messages to editor - const queuedMessages = this.session.clearQueue(); - const queuedText = queuedMessages.join("\n\n"); + const { steering, followUp } = this.session.clearQueue(); + const allQueued = [...steering, ...followUp]; + const queuedText = allQueued.join("\n\n"); const currentText = this.editor.getText(); const combinedText = [queuedText, currentText].filter((t) => t.trim()).join("\n\n"); this.editor.setText(combinedText); @@ -637,6 +779,7 @@ export class InteractiveMode { this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); + this.editor.onAltEnter = () => this.handleAltEnter(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -668,6 +811,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/share") { + await this.handleShareCommand(); + this.editor.setText(""); + return; + } if (text === "/copy") { this.handleCopyCommand(); this.editor.setText(""); @@ -776,9 +924,9 @@ export class InteractiveMode { } } - // Queue regular messages if agent is streaming + // Queue steering message if agent is streaming (interrupts current work) if (this.session.isStreaming) { - await this.session.queueMessage(text); + await this.session.steer(text); this.updatePendingMessagesDisplay(); this.editor.addToHistory(text); this.editor.setText(""); @@ -799,16 +947,16 @@ export class InteractiveMode { private subscribeToAgent(): void { this.unsubscribe = this.session.subscribe(async (event) => { - await this.handleEvent(event, this.session.state); + await this.handleEvent(event); }); } - private async handleEvent(event: AgentSessionEvent, state: AgentState): Promise { + private async handleEvent(event: AgentSessionEvent): Promise { if (!this.isInitialized) { await this.init(); } - this.footer.updateState(state); + this.footer.invalidate(); switch (event.type) { case "agent_start": @@ -837,18 +985,19 @@ export class InteractiveMode { this.ui.requestRender(); } else if (event.message.role === "assistant") { this.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock); + this.streamingMessage = event.message; this.chatContainer.addChild(this.streamingComponent); - this.streamingComponent.updateContent(event.message); + this.streamingComponent.updateContent(this.streamingMessage); this.ui.requestRender(); } break; case "message_update": if (this.streamingComponent && event.message.role === "assistant") { - const assistantMsg = event.message as AssistantMessage; - this.streamingComponent.updateContent(assistantMsg); + this.streamingMessage = event.message; + this.streamingComponent.updateContent(this.streamingMessage); - for (const content of assistantMsg.content) { + for (const content of this.streamingMessage.content) { if (content.type === "toolCall") { if (!this.pendingTools.has(content.id)) { this.chatContainer.addChild(new Text("", 0, 0)); @@ -861,6 +1010,7 @@ export class InteractiveMode { this.customTools.get(content.name)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(content.id, component); } else { @@ -878,12 +1028,14 @@ export class InteractiveMode { case "message_end": if (event.message.role === "user") break; if (this.streamingComponent && event.message.role === "assistant") { - const assistantMsg = event.message as AssistantMessage; - this.streamingComponent.updateContent(assistantMsg); + this.streamingMessage = event.message; + this.streamingComponent.updateContent(this.streamingMessage); - if (assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error") { + if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") { const errorMessage = - assistantMsg.stopReason === "aborted" ? "Operation aborted" : assistantMsg.errorMessage || "Error"; + this.streamingMessage.stopReason === "aborted" + ? "Operation aborted" + : this.streamingMessage.errorMessage || "Error"; for (const [, component] of this.pendingTools.entries()) { component.updateResult({ content: [{ type: "text", text: errorMessage }], @@ -891,8 +1043,14 @@ export class InteractiveMode { }); } this.pendingTools.clear(); + } else { + // Args are now complete - trigger diff computation for edit tools + for (const [, component] of this.pendingTools.entries()) { + component.setArgsComplete(); + } } this.streamingComponent = undefined; + this.streamingMessage = undefined; this.footer.invalidate(); } this.ui.requestRender(); @@ -909,6 +1067,7 @@ export class InteractiveMode { this.customTools.get(event.toolName)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); this.pendingTools.set(event.toolCallId, component); this.ui.requestRender(); @@ -944,6 +1103,7 @@ export class InteractiveMode { if (this.streamingComponent) { this.chatContainer.removeChild(this.streamingComponent); this.streamingComponent = undefined; + this.streamingMessage = undefined; } this.pendingTools.clear(); this.ui.requestRender(); @@ -999,7 +1159,7 @@ export class InteractiveMode { summary: event.result.summary, timestamp: Date.now(), }); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } this.ui.requestRender(); break; @@ -1057,10 +1217,29 @@ export class InteractiveMode { return textBlocks.map((c) => (c as { text: string }).text).join(""); } - /** Show a status message in the chat */ + /** + * Show a status message in the chat. + * + * If multiple status messages are emitted back-to-back (without anything else being added to the chat), + * we update the previous status line instead of appending new ones to avoid log spam. + */ private showStatus(message: string): void { - this.chatContainer.addChild(new Spacer(1)); - this.chatContainer.addChild(new Text(theme.fg("dim", message), 1, 0)); + const children = this.chatContainer.children; + const last = children.length > 0 ? children[children.length - 1] : undefined; + const secondLast = children.length > 1 ? children[children.length - 2] : undefined; + + if (last && secondLast && last === this.lastStatusText && secondLast === this.lastStatusSpacer) { + this.lastStatusText.setText(theme.fg("dim", message)); + this.ui.requestRender(); + return; + } + + const spacer = new Spacer(1); + const text = new Text(theme.fg("dim", message), 1, 0); + this.chatContainer.addChild(spacer); + this.chatContainer.addChild(text); + this.lastStatusSpacer = spacer; + this.lastStatusText = text; this.ui.requestRender(); } @@ -1140,7 +1319,7 @@ export class InteractiveMode { this.pendingTools.clear(); if (options.updateFooter) { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); } @@ -1158,6 +1337,7 @@ export class InteractiveMode { this.customTools.get(content.name)?.tool, this.ui, ); + component.setExpanded(this.toolOutputExpanded); this.chatContainer.addChild(component); if (message.stopReason === "aborted" || message.stopReason === "error") { @@ -1251,7 +1431,7 @@ export class InteractiveMode { } // Emit shutdown event to custom tools - await this.session.emitToolSessionEvent("shutdown"); + await this.session.emitCustomToolSessionEvent("shutdown"); this.stop(); process.exit(0); @@ -1271,6 +1451,24 @@ export class InteractiveMode { process.kill(0, "SIGTSTP"); } + private async handleAltEnter(): Promise { + const text = this.editor.getText().trim(); + if (!text) return; + + // Alt+Enter queues a follow-up message (waits until agent finishes) + if (this.session.isStreaming) { + await this.session.followUp(text); + this.updatePendingMessagesDisplay(); + this.editor.addToHistory(text); + this.editor.setText(""); + this.ui.requestRender(); + } + // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) + else if (this.editor.onSubmit) { + this.editor.onSubmit(text); + } + } + private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); @@ -1286,7 +1484,7 @@ export class InteractiveMode { if (newLevel === undefined) { this.showStatus("Current model does not support thinking"); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); this.showStatus(`Thinking level: ${newLevel}`); } @@ -1299,7 +1497,7 @@ export class InteractiveMode { const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available"; this.showStatus(msg); } else { - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); const thinkingStr = result.model.reasoning && result.thinkingLevel !== "off" ? ` (thinking: ${result.thinkingLevel})` : ""; @@ -1324,14 +1522,17 @@ export class InteractiveMode { this.hideThinkingBlock = !this.hideThinkingBlock; this.settingsManager.setHideThinkingBlock(this.hideThinkingBlock); - for (const child of this.chatContainer.children) { - if (child instanceof AssistantMessageComponent) { - child.setHideThinkingBlock(this.hideThinkingBlock); - } - } - + // Rebuild chat from session messages this.chatContainer.clear(); this.rebuildChatFromMessages(); + + // If streaming, re-add the streaming component with updated visibility and re-render + if (this.streamingComponent && this.streamingMessage) { + this.streamingComponent.setHideThinkingBlock(this.hideThinkingBlock); + this.streamingComponent.updateContent(this.streamingMessage); + this.chatContainer.addChild(this.streamingComponent); + } + this.showStatus(`Thinking blocks: ${this.hideThinkingBlock ? "hidden" : "visible"}`); } @@ -1421,12 +1622,17 @@ export class InteractiveMode { private updatePendingMessagesDisplay(): void { this.pendingMessagesContainer.clear(); - const queuedMessages = this.session.getQueuedMessages(); - if (queuedMessages.length > 0) { + const steeringMessages = this.session.getSteeringMessages(); + const followUpMessages = this.session.getFollowUpMessages(); + if (steeringMessages.length > 0 || followUpMessages.length > 0) { this.pendingMessagesContainer.addChild(new Spacer(1)); - for (const message of queuedMessages) { - const queuedText = theme.fg("dim", `Queued: ${message}`); - this.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0)); + for (const message of steeringMessages) { + const text = theme.fg("dim", `Steering: ${message}`); + this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); + } + for (const message of followUpMessages) { + const text = theme.fg("dim", `Follow-up: ${message}`); + this.pendingMessagesContainer.addChild(new TruncatedText(text, 1, 0)); } } } @@ -1467,7 +1673,9 @@ export class InteractiveMode { { autoCompact: this.session.autoCompactionEnabled, showImages: this.settingsManager.getShowImages(), - queueMode: this.session.queueMode, + autoResizeImages: this.settingsManager.getImageAutoResize(), + steeringMode: this.session.steeringMode, + followUpMode: this.session.followUpMode, thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", @@ -1488,12 +1696,18 @@ export class InteractiveMode { } } }, - onQueueModeChange: (mode) => { - this.session.setQueueMode(mode); + onAutoResizeImagesChange: (enabled) => { + this.settingsManager.setImageAutoResize(enabled); + }, + onSteeringModeChange: (mode) => { + this.session.setSteeringMode(mode); + }, + onFollowUpModeChange: (mode) => { + this.session.setFollowUpMode(mode); }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); }, onThemeChange: (themeName) => { @@ -1546,7 +1760,7 @@ export class InteractiveMode { async (model) => { try { await this.session.setModel(model); - this.footer.updateState(this.session.state); + this.footer.invalidate(); this.updateEditorBorderColor(); done(); this.showStatus(`Model: ${model.id}`); @@ -1574,9 +1788,9 @@ export class InteractiveMode { this.showSelector((done) => { const selector = new UserMessageSelectorComponent( - userMessages.map((m) => ({ index: m.entryIndex, text: m.text })), - async (entryIndex) => { - const result = await this.session.branch(entryIndex); + userMessages.map((m) => ({ id: m.entryId, text: m.text })), + async (entryId) => { + const result = await this.session.branch(entryId); if (result.cancelled) { // Hook cancelled the branch done(); @@ -1733,6 +1947,7 @@ export class InteractiveMode { // Clear UI state this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; + this.streamingMessage = undefined; this.pendingTools.clear(); // Switch session via AgentSession (emits hook and tool session events) @@ -1871,6 +2086,100 @@ export class InteractiveMode { } } + private async handleShareCommand(): Promise { + // Check if gh is available and logged in + try { + const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + if (authResult.status !== 0) { + this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); + return; + } + } catch { + this.showError("GitHub CLI (gh) is not installed. Install it from https://cli.github.com/"); + return; + } + + // Export to a temp file + const tmpFile = path.join(os.tmpdir(), "session.html"); + try { + this.session.exportToHtml(tmpFile); + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return; + } + + // Show cancellable loader, replacing the editor + const loader = new BorderedLoader(this.ui, theme, "Creating gist..."); + this.editorContainer.clear(); + this.editorContainer.addChild(loader); + this.ui.setFocus(loader); + this.ui.requestRender(); + + const restoreEditor = () => { + loader.dispose(); + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + }; + + // Create a secret gist asynchronously + let proc: ReturnType | null = null; + + loader.onAbort = () => { + proc?.kill(); + restoreEditor(); + this.showStatus("Share cancelled"); + }; + + try { + const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (data) => { + stdout += data.toString(); + }); + proc.stderr?.on("data", (data) => { + stderr += data.toString(); + }); + proc.on("close", (code) => resolve({ stdout, stderr, code })); + }); + + if (loader.signal.aborted) return; + + restoreEditor(); + + if (result.code !== 0) { + const errorMsg = result.stderr?.trim() || "Unknown error"; + this.showError(`Failed to create gist: ${errorMsg}`); + return; + } + + // Extract gist ID from the URL returned by gh + // gh returns something like: https://gist.github.com/username/GIST_ID + const gistUrl = result.stdout?.trim(); + const gistId = gistUrl?.split("/").pop(); + if (!gistId) { + this.showError("Failed to parse gist ID from gh output"); + return; + } + + // Create the preview URL + const previewUrl = `https://shittycodingagent.ai/session?${gistId}`; + this.showStatus(`Share URL: ${previewUrl}\nGist: ${gistUrl}`); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(); + this.showError(`Failed to create gist: ${error instanceof Error ? error.message : "Unknown error"}`); + } + } + } + private handleCopyCommand(): void { const text = this.session.getLastAssistantText(); if (!text) { @@ -1992,13 +2301,14 @@ export class InteractiveMode { } this.statusContainer.clear(); - // Reset via session (emits hook and tool session events) - await this.session.reset(); + // New session via session (emits hook and tool session events) + await this.session.newSession(); // Clear UI state this.chatContainer.clear(); this.pendingMessagesContainer.clear(); this.streamingComponent = undefined; + this.streamingMessage = undefined; this.pendingTools.clear(); this.chatContainer.addChild(new Spacer(1)); @@ -2133,7 +2443,7 @@ export class InteractiveMode { const msg = createCompactionSummaryMessage(result.summary, result.tokensBefore, new Date().toISOString()); this.addMessageToChat(msg); - this.footer.updateState(this.session.state); + this.footer.invalidate(); } catch (error) { const message = error instanceof Error ? error.message : String(error); if (message === "Compaction cancelled" || (error instanceof Error && error.name === "AbortError")) { diff --git a/packages/coding-agent/src/modes/interactive/theme/dark.json b/packages/coding-agent/src/modes/interactive/theme/dark.json index f55be9f7..cc909781 100644 --- a/packages/coding-agent/src/modes/interactive/theme/dark.json +++ b/packages/coding-agent/src/modes/interactive/theme/dark.json @@ -29,6 +29,7 @@ "muted": "gray", "dim": "dimGray", "text": "", + "thinkingText": "gray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", @@ -75,5 +76,10 @@ "thinkingXhigh": "#d183e8", "bashMode": "green" + }, + "export": { + "pageBg": "#18181e", + "cardBg": "#1e1e24", + "infoBg": "#3c3728" } } diff --git a/packages/coding-agent/src/modes/interactive/theme/light.json b/packages/coding-agent/src/modes/interactive/theme/light.json index a4276853..9154b161 100644 --- a/packages/coding-agent/src/modes/interactive/theme/light.json +++ b/packages/coding-agent/src/modes/interactive/theme/light.json @@ -28,6 +28,7 @@ "muted": "mediumGray", "dim": "dimGray", "text": "", + "thinkingText": "mediumGray", "selectedBg": "selectedBg", "userMessageBg": "userMsgBg", @@ -74,5 +75,10 @@ "thinkingXhigh": "#8b008b", "bashMode": "green" + }, + "export": { + "pageBg": "#f8f8f8", + "cardBg": "#ffffff", + "infoBg": "#fffae6" } } diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json index f561ecb1..820813e0 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-schema.json +++ b/packages/coding-agent/src/modes/interactive/theme/theme-schema.json @@ -267,6 +267,25 @@ } }, "additionalProperties": false + }, + "export": { + "type": "object", + "description": "Optional colors for HTML export (defaults derived from userMessageBg if not specified)", + "properties": { + "pageBg": { + "$ref": "#/$defs/colorValue", + "description": "Page background color" + }, + "cardBg": { + "$ref": "#/$defs/colorValue", + "description": "Card/container background color" + }, + "infoBg": { + "$ref": "#/$defs/colorValue", + "description": "Info sections background (system prompt, notices)" + } + }, + "additionalProperties": false } }, "additionalProperties": false, diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c0d8bf66..5b5155e6 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -34,6 +34,7 @@ const ThemeJsonSchema = Type.Object({ muted: ColorValueSchema, dim: ColorValueSchema, text: ColorValueSchema, + thinkingText: ColorValueSchema, // Backgrounds & Content Text (11 colors) selectedBg: ColorValueSchema, userMessageBg: ColorValueSchema, @@ -81,6 +82,13 @@ const ThemeJsonSchema = Type.Object({ // Bash Mode (1 color) bashMode: ColorValueSchema, }), + export: Type.Optional( + Type.Object({ + pageBg: Type.Optional(ColorValueSchema), + cardBg: Type.Optional(ColorValueSchema), + infoBg: Type.Optional(ColorValueSchema), + }), + ), }); type ThemeJson = Static; @@ -98,6 +106,7 @@ export type ThemeColor = | "muted" | "dim" | "text" + | "thinkingText" | "userMessageText" | "customMessageText" | "customMessageLabel" @@ -650,6 +659,129 @@ export function stopThemeWatcher(): void { } } +// ============================================================================ +// HTML Export Helpers +// ============================================================================ + +/** + * Convert a 256-color index to hex string. + * Indices 0-15: basic colors (approximate) + * Indices 16-231: 6x6x6 color cube + * Indices 232-255: grayscale ramp + */ +function ansi256ToHex(index: number): string { + // Basic colors (0-15) - approximate common terminal values + const basicColors = [ + "#000000", + "#800000", + "#008000", + "#808000", + "#000080", + "#800080", + "#008080", + "#c0c0c0", + "#808080", + "#ff0000", + "#00ff00", + "#ffff00", + "#0000ff", + "#ff00ff", + "#00ffff", + "#ffffff", + ]; + if (index < 16) { + return basicColors[index]; + } + + // Color cube (16-231): 6x6x6 = 216 colors + if (index < 232) { + const cubeIndex = index - 16; + const r = Math.floor(cubeIndex / 36); + const g = Math.floor((cubeIndex % 36) / 6); + const b = cubeIndex % 6; + const toHex = (n: number) => (n === 0 ? 0 : 55 + n * 40).toString(16).padStart(2, "0"); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; + } + + // Grayscale (232-255): 24 shades + const gray = 8 + (index - 232) * 10; + const grayHex = gray.toString(16).padStart(2, "0"); + return `#${grayHex}${grayHex}${grayHex}`; +} + +/** + * Get resolved theme colors as CSS-compatible hex strings. + * Used by HTML export to generate CSS custom properties. + */ +export function getResolvedThemeColors(themeName?: string): Record { + const name = themeName ?? getDefaultTheme(); + const isLight = name === "light"; + const themeJson = loadThemeJson(name); + const resolved = resolveThemeColors(themeJson.colors, themeJson.vars); + + // Default text color for empty values (terminal uses default fg color) + const defaultText = isLight ? "#000000" : "#e5e5e7"; + + const cssColors: Record = {}; + for (const [key, value] of Object.entries(resolved)) { + if (typeof value === "number") { + cssColors[key] = ansi256ToHex(value); + } else if (value === "") { + // Empty means default terminal color - use sensible fallback for HTML + cssColors[key] = defaultText; + } else { + cssColors[key] = value; + } + } + return cssColors; +} + +/** + * Check if a theme is a "light" theme (for CSS that needs light/dark variants). + */ +export function isLightTheme(themeName?: string): boolean { + // Currently just check the name - could be extended to analyze colors + return themeName === "light"; +} + +/** + * Get explicit export colors from theme JSON, if specified. + * Returns undefined for each color that isn't explicitly set. + */ +export function getThemeExportColors(themeName?: string): { + pageBg?: string; + cardBg?: string; + infoBg?: string; +} { + const name = themeName ?? getDefaultTheme(); + try { + const themeJson = loadThemeJson(name); + const exportSection = themeJson.export; + if (!exportSection) return {}; + + const vars = themeJson.vars ?? {}; + const resolve = (value: string | number | undefined): string | undefined => { + if (value === undefined) return undefined; + if (typeof value === "number") return ansi256ToHex(value); + if (value.startsWith("$")) { + const resolved = vars[value]; + if (resolved === undefined) return undefined; + if (typeof resolved === "number") return ansi256ToHex(resolved); + return resolved; + } + return value; + }; + + return { + pageBg: resolve(exportSection.pageBg), + cardBg: resolve(exportSection.cardBg), + infoBg: resolve(exportSection.infoBg), + }; + } catch { + return {}; + } +} + // ============================================================================ // TUI Helpers // ============================================================================ diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts index fbf3037a..56c53420 100644 --- a/packages/coding-agent/src/modes/print-mode.ts +++ b/packages/coding-agent/src/modes/print-mode.ts @@ -26,25 +26,24 @@ export async function runPrintMode( initialMessage?: string, initialImages?: ImageContent[], ): Promise { - // Load entries once for session start events - const entries = session.sessionManager.getEntries(); - // Hook runner already has no-op UI context by default (set in main.ts) // Set up hooks for print mode (no UI) const hookRunner = session.hookRunner; if (hookRunner) { + hookRunner.initialize({ + getModel: () => session.model, + sendMessageHandler: (message, options) => { + session.sendHookMessage(message, options).catch((e) => { + console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); + }); + }, + appendEntryHandler: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + }); hookRunner.onError((err) => { console.error(`Hook error (${err.hookPath}): ${err.error}`); }); - // Set up handlers - sendHookMessage handles queuing/direct append as needed - hookRunner.setSendMessageHandler((message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - console.error(`Hook sendMessage failed: ${e instanceof Error ? e.message : String(e)}`); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }); // Emit session_start event await hookRunner.emit({ type: "session_start", @@ -55,12 +54,22 @@ export async function runPrintMode( for (const { tool } of session.customTools) { if (tool.onSession) { try { - await tool.onSession({ - entries, - sessionFile: session.sessionFile, - previousSessionFile: undefined, - reason: "start", - }); + await tool.onSession( + { + reason: "start", + previousSessionFile: undefined, + }, + { + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, + model: session.model, + isIdle: () => !session.isStreaming, + hasPendingMessages: () => session.pendingMessageCount > 0, + abort: () => { + session.abort(); + }, + }, + ); } catch (_err) { // Silently ignore tool errors } diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 0249ca11..39b89156 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -173,10 +173,17 @@ export class RpcClient { } /** - * Queue a message while agent is streaming. + * Queue a steering message to interrupt the agent mid-run. */ - async queueMessage(message: string): Promise { - await this.send({ type: "queue_message", message }); + async steer(message: string): Promise { + await this.send({ type: "steer", message }); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + */ + async followUp(message: string): Promise { + await this.send({ type: "follow_up", message }); } /** @@ -187,11 +194,12 @@ export class RpcClient { } /** - * Reset session (clear all messages). - * @returns Object with `cancelled: true` if a hook cancelled the reset + * Start a new session, optionally with parent tracking. + * @param parentSession - Optional parent session path for lineage tracking + * @returns Object with `cancelled: true` if a hook cancelled the new session */ - async reset(): Promise<{ cancelled: boolean }> { - const response = await this.send({ type: "reset" }); + async newSession(parentSession?: string): Promise<{ cancelled: boolean }> { + const response = await this.send({ type: "new_session", parentSession }); return this.getData(response); } @@ -247,10 +255,17 @@ export class RpcClient { } /** - * Set queue mode. + * Set steering mode. */ - async setQueueMode(mode: "all" | "one-at-a-time"): Promise { - await this.send({ type: "set_queue_mode", mode }); + async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_steering_mode", mode }); + } + + /** + * Set follow-up mode. + */ + async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_follow_up_mode", mode }); } /** @@ -326,17 +341,17 @@ export class RpcClient { * Branch from a specific message. * @returns Object with `text` (the message text) and `cancelled` (if hook cancelled) */ - async branch(entryIndex: number): Promise<{ text: string; cancelled: boolean }> { - const response = await this.send({ type: "branch", entryIndex }); + async branch(entryId: string): Promise<{ text: string; cancelled: boolean }> { + const response = await this.send({ type: "branch", entryId }); return this.getData(response); } /** * Get messages available for branching. */ - async getBranchMessages(): Promise> { + async getBranchMessages(): Promise> { const response = await this.send({ type: "get_branch_messages" }); - return this.getData<{ messages: Array<{ entryIndex: number; text: string }> }>(response).messages; + return this.getData<{ messages: Array<{ entryId: string; text: string }> }>(response).messages; } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index de378612..c57534e9 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -15,6 +15,7 @@ import * as crypto from "node:crypto"; import * as readline from "readline"; import type { AgentSession } from "../../core/agent-session.js"; import type { HookUIContext } from "../../core/hooks/index.js"; +import { theme } from "../interactive/theme/theme.js"; import type { RpcCommand, RpcHookUIRequest, RpcHookUIResponse, RpcResponse, RpcSessionState } from "./rpc-types.js"; // Re-export types for consumers @@ -119,31 +120,81 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcHookUIRequest); }, - custom() { + setStatus(key: string, text: string | undefined): void { + // Fire and forget - no response needed + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "setStatus", + statusKey: key, + statusText: text, + } as RpcHookUIRequest); + }, + + async custom() { // Custom UI not supported in RPC mode - return { close: () => {}, requestRender: () => {} }; + return undefined as never; + }, + + setEditorText(text: string): void { + // Fire and forget - host can implement editor control + output({ + type: "hook_ui_request", + id: crypto.randomUUID(), + method: "set_editor_text", + text, + } as RpcHookUIRequest); + }, + + getEditorText(): string { + // Synchronous method can't wait for RPC response + // Host should track editor state locally if needed + return ""; + }, + + async editor(title: string, prefill?: string): Promise { + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingHookRequests.set(id, { + resolve: (response: RpcHookUIResponse) => { + if ("cancelled" in response && response.cancelled) { + resolve(undefined); + } else if ("value" in response) { + resolve(response.value); + } else { + resolve(undefined); + } + }, + reject, + }); + output({ type: "hook_ui_request", id, method: "editor", title, prefill } as RpcHookUIRequest); + }); + }, + + get theme() { + return theme; }, }); - // Load entries once for session start events - const entries = session.sessionManager.getEntries(); - // Set up hooks with RPC-based UI context const hookRunner = session.hookRunner; if (hookRunner) { - hookRunner.setUIContext(createHookUIContext(), false); + hookRunner.initialize({ + getModel: () => session.agent.state.model, + sendMessageHandler: (message, options) => { + session.sendHookMessage(message, options).catch((e) => { + output(error(undefined, "hook_send", e.message)); + }); + }, + appendEntryHandler: (customType, data) => { + session.sessionManager.appendCustomEntry(customType, data); + }, + uiContext: createHookUIContext(), + hasUI: false, + }); hookRunner.onError((err) => { output({ type: "hook_error", hookPath: err.hookPath, event: err.event, error: err.error }); }); - // Set up handlers for pi.sendMessage() and pi.appendEntry() - hookRunner.setSendMessageHandler((message, triggerTurn) => { - session.sendHookMessage(message, triggerTurn).catch((e) => { - output(error(undefined, "hook_send", e.message)); - }); - }); - hookRunner.setAppendEntryHandler((customType, data) => { - session.sessionManager.appendCustomEntry(customType, data); - }); // Emit session_start event await hookRunner.emit({ type: "session_start", @@ -155,12 +206,22 @@ export async function runRpcMode(session: AgentSession): Promise { for (const { tool } of session.customTools) { if (tool.onSession) { try { - await tool.onSession({ - entries, - sessionFile: session.sessionFile, - previousSessionFile: undefined, - reason: "start", - }); + await tool.onSession( + { + previousSessionFile: undefined, + reason: "start", + }, + { + sessionManager: session.sessionManager, + modelRegistry: session.modelRegistry, + model: session.model, + isIdle: () => !session.isStreaming, + hasPendingMessages: () => session.pendingMessageCount > 0, + abort: () => { + session.abort(); + }, + }, + ); } catch (_err) { // Silently ignore tool errors } @@ -192,9 +253,14 @@ export async function runRpcMode(session: AgentSession): Promise { return success(id, "prompt"); } - case "queue_message": { - await session.queueMessage(command.message); - return success(id, "queue_message"); + case "steer": { + await session.steer(command.message); + return success(id, "steer"); + } + + case "follow_up": { + await session.followUp(command.message); + return success(id, "follow_up"); } case "abort": { @@ -202,9 +268,10 @@ export async function runRpcMode(session: AgentSession): Promise { return success(id, "abort"); } - case "reset": { - const cancelled = !(await session.reset()); - return success(id, "reset", { cancelled }); + case "new_session": { + const options = command.parentSession ? { parentSession: command.parentSession } : undefined; + const cancelled = !(await session.newSession(options)); + return success(id, "new_session", { cancelled }); } // ================================================================= @@ -217,12 +284,13 @@ export async function runRpcMode(session: AgentSession): Promise { thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, - queueMode: session.queueMode, + steeringMode: session.steeringMode, + followUpMode: session.followUpMode, sessionFile: session.sessionFile, sessionId: session.sessionId, autoCompactionEnabled: session.autoCompactionEnabled, messageCount: session.messages.length, - queuedMessageCount: session.queuedMessageCount, + pendingMessageCount: session.pendingMessageCount, }; return success(id, "get_state", state); } @@ -272,12 +340,17 @@ export async function runRpcMode(session: AgentSession): Promise { } // ================================================================= - // Queue Mode + // Queue Modes // ================================================================= - case "set_queue_mode": { - session.setQueueMode(command.mode); - return success(id, "set_queue_mode"); + case "set_steering_mode": { + session.setSteeringMode(command.mode); + return success(id, "set_steering_mode"); + } + + case "set_follow_up_mode": { + session.setFollowUpMode(command.mode); + return success(id, "set_follow_up_mode"); } // ================================================================= @@ -342,7 +415,7 @@ export async function runRpcMode(session: AgentSession): Promise { } case "branch": { - const result = await session.branch(command.entryIndex); + const result = await session.branch(command.entryId); return success(id, "branch", { text: result.selectedText, cancelled: result.cancelled }); } diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 5feead90..5062f64a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -18,9 +18,10 @@ import type { CompactionResult } from "../../core/compaction/index.js"; export type RpcCommand = // Prompting | { id?: string; type: "prompt"; message: string; images?: ImageContent[] } - | { id?: string; type: "queue_message"; message: string } + | { id?: string; type: "steer"; message: string } + | { id?: string; type: "follow_up"; message: string } | { id?: string; type: "abort" } - | { id?: string; type: "reset" } + | { id?: string; type: "new_session"; parentSession?: string } // State | { id?: string; type: "get_state" } @@ -34,8 +35,9 @@ export type RpcCommand = | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } | { id?: string; type: "cycle_thinking_level" } - // Queue mode - | { id?: string; type: "set_queue_mode"; mode: "all" | "one-at-a-time" } + // Queue modes + | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } + | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } // Compaction | { id?: string; type: "compact"; customInstructions?: string } @@ -53,7 +55,7 @@ export type RpcCommand = | { id?: string; type: "get_session_stats" } | { id?: string; type: "export_html"; outputPath?: string } | { id?: string; type: "switch_session"; sessionPath: string } - | { id?: string; type: "branch"; entryIndex: number } + | { id?: string; type: "branch"; entryId: string } | { id?: string; type: "get_branch_messages" } | { id?: string; type: "get_last_assistant_text" } @@ -69,12 +71,13 @@ export interface RpcSessionState { thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; - queueMode: "all" | "one-at-a-time"; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; sessionFile?: string; sessionId: string; autoCompactionEnabled: boolean; messageCount: number; - queuedMessageCount: number; + pendingMessageCount: number; } // ============================================================================ @@ -85,9 +88,10 @@ export interface RpcSessionState { export type RpcResponse = // Prompting (async - events follow) | { id?: string; type: "response"; command: "prompt"; success: true } - | { id?: string; type: "response"; command: "queue_message"; success: true } + | { id?: string; type: "response"; command: "steer"; success: true } + | { id?: string; type: "response"; command: "follow_up"; success: true } | { id?: string; type: "response"; command: "abort"; success: true } - | { id?: string; type: "response"; command: "reset"; success: true; data: { cancelled: boolean } } + | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } } // State | { id?: string; type: "response"; command: "get_state"; success: true; data: RpcSessionState } @@ -125,8 +129,9 @@ export type RpcResponse = data: { level: ThinkingLevel } | null; } - // Queue mode - | { id?: string; type: "response"; command: "set_queue_mode"; success: true } + // Queue modes + | { id?: string; type: "response"; command: "set_steering_mode"; success: true } + | { id?: string; type: "response"; command: "set_follow_up_mode"; success: true } // Compaction | { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult } @@ -150,7 +155,7 @@ export type RpcResponse = type: "response"; command: "get_branch_messages"; success: true; - data: { messages: Array<{ entryIndex: number; text: string }> }; + data: { messages: Array<{ entryId: string; text: string }> }; } | { id?: string; @@ -175,13 +180,16 @@ export type RpcHookUIRequest = | { type: "hook_ui_request"; id: string; method: "select"; title: string; options: string[] } | { type: "hook_ui_request"; id: string; method: "confirm"; title: string; message: string } | { type: "hook_ui_request"; id: string; method: "input"; title: string; placeholder?: string } + | { type: "hook_ui_request"; id: string; method: "editor"; title: string; prefill?: string } | { type: "hook_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error"; - }; + } + | { type: "hook_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText: string | undefined } + | { type: "hook_ui_request"; id: string; method: "set_editor_text"; text: string }; // ============================================================================ // Hook UI Commands (stdin) diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts new file mode 100644 index 00000000..3505cff9 --- /dev/null +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -0,0 +1,136 @@ +import type { ImageContent } from "@mariozechner/pi-ai"; + +export interface ImageResizeOptions { + maxWidth?: number; // Default: 2000 + maxHeight?: number; // Default: 2000 + jpegQuality?: number; // Default: 80 +} + +export interface ResizedImage { + data: string; // base64 + mimeType: string; + originalWidth: number; + originalHeight: number; + width: number; + height: number; + wasResized: boolean; +} + +const DEFAULT_OPTIONS: Required = { + maxWidth: 2000, + maxHeight: 2000, + jpegQuality: 80, +}; + +/** + * Resize an image to fit within the specified max dimensions. + * Returns the original image if it already fits within the limits. + * + * Uses sharp for image processing. If sharp is not available (e.g., in some + * environments), returns the original image unchanged. + */ +export async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise { + const opts = { ...DEFAULT_OPTIONS, ...options }; + const buffer = Buffer.from(img.data, "base64"); + + let sharp: typeof import("sharp") | undefined; + try { + sharp = (await import("sharp")).default; + } catch { + // Sharp not available - return original image + // We can't get dimensions without sharp, so return 0s + return { + data: img.data, + mimeType: img.mimeType, + originalWidth: 0, + originalHeight: 0, + width: 0, + height: 0, + wasResized: false, + }; + } + + const sharpImg = sharp(buffer); + const metadata = await sharpImg.metadata(); + + const width = metadata.width ?? 0; + const height = metadata.height ?? 0; + const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png"; + + // Check if already within limits + if (width <= opts.maxWidth && height <= opts.maxHeight) { + return { + data: img.data, + mimeType: img.mimeType ?? `image/${format}`, + originalWidth: width, + originalHeight: height, + width, + height, + wasResized: false, + }; + } + + // Calculate new dimensions maintaining aspect ratio + let newWidth = width; + let newHeight = height; + + if (newWidth > opts.maxWidth) { + newHeight = Math.round((newHeight * opts.maxWidth) / newWidth); + newWidth = opts.maxWidth; + } + if (newHeight > opts.maxHeight) { + newWidth = Math.round((newWidth * opts.maxHeight) / newHeight); + newHeight = opts.maxHeight; + } + + // Resize the image + const resized = await sharp(buffer) + .resize(newWidth, newHeight, { fit: "inside", withoutEnlargement: true }) + .toBuffer(); + + // Determine output format - preserve original if possible, otherwise use JPEG + let outputMimeType: string; + let outputBuffer: Buffer; + + if (format === "jpeg" || format === "jpg") { + outputBuffer = await sharp(resized).jpeg({ quality: opts.jpegQuality }).toBuffer(); + outputMimeType = "image/jpeg"; + } else if (format === "png") { + outputBuffer = resized; + outputMimeType = "image/png"; + } else if (format === "gif") { + // GIF resize might not preserve animation; convert to PNG for quality + outputBuffer = resized; + outputMimeType = "image/png"; + } else if (format === "webp") { + outputBuffer = resized; + outputMimeType = "image/webp"; + } else { + // Default to JPEG for unknown formats + outputBuffer = await sharp(resized).jpeg({ quality: opts.jpegQuality }).toBuffer(); + outputMimeType = "image/jpeg"; + } + + return { + data: outputBuffer.toString("base64"), + mimeType: outputMimeType, + originalWidth: width, + originalHeight: height, + width: newWidth, + height: newHeight, + wasResized: true, + }; +} + +/** + * Format a dimension note for resized images. + * This helps the model understand the coordinate mapping. + */ +export function formatDimensionNote(result: ResizedImage): string | undefined { + if (!result.wasResized) { + return undefined; + } + + const scale = result.originalWidth / result.width; + return `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`; +} diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index b9e45f64..ab838bfd 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -100,13 +100,38 @@ export function getShellConfig(): { shell: string; args: string[] } { * - Control characters (except tab, newline, carriage return) * - Lone surrogates * - Unicode Format characters (crash string-width due to a bug) + * - Characters with undefined code points */ export function sanitizeBinaryOutput(str: string): string { - // Fast path: use regex to remove problematic characters - // - \p{Format}: Unicode format chars like \u0601 that crash string-width - // - \p{Surrogate}: Lone surrogates from invalid UTF-8 - // - Control chars except \t \n \r - return str.replace(/[\p{Format}\p{Surrogate}]/gu, "").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, ""); + // Use Array.from to properly iterate over code points (not code units) + // This handles surrogate pairs correctly and catches edge cases where + // codePointAt() might return undefined + return Array.from(str) + .filter((char) => { + // Filter out characters that cause string-width to crash + // This includes: + // - Unicode format characters + // - Lone surrogates (already filtered by Array.from) + // - Control chars except \t \n \r + // - Characters with undefined code points + + const code = char.codePointAt(0); + + // Skip if code point is undefined (edge case with invalid strings) + if (code === undefined) return false; + + // Allow tab, newline, carriage return + if (code === 0x09 || code === 0x0a || code === 0x0d) return true; + + // Filter out control characters (0x00-0x1F, except 0x09, 0x0a, 0x0x0d) + if (code <= 0x1f) return false; + + // Filter out Unicode format characters + if (code >= 0xfff9 && code <= 0xfffb) return false; + + return true; + }) + .join(""); } /** diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index 71b78ab7..33f70853 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -83,7 +83,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { expect(userMessages[0].text).toBe("Say hello"); // Branch from the first message - const result = await session.branch(userMessages[0].entryIndex); + const result = await session.branch(userMessages[0].entryId); expect(result.selectedText).toBe("Say hello"); expect(result.cancelled).toBe(false); @@ -113,7 +113,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { expect(session.messages.length).toBeGreaterThan(0); // Branch from the first message - const result = await session.branch(userMessages[0].entryIndex); + const result = await session.branch(userMessages[0].entryId); expect(result.selectedText).toBe("Say hi"); expect(result.cancelled).toBe(false); @@ -143,7 +143,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { // Branch from second message (keeps first message + response) const secondMessage = userMessages[1]; - const result = await session.branch(secondMessage.entryIndex); + const result = await session.branch(secondMessage.entryId); expect(result.selectedText).toBe("Say two"); // After branching, should have first user message + assistant response diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts new file mode 100644 index 00000000..2458d879 --- /dev/null +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -0,0 +1,212 @@ +/** + * Tests for AgentSession concurrent prompt guard. + */ + +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Agent } from "@mariozechner/pi-agent-core"; +import { type AssistantMessage, type AssistantMessageEvent, EventStream, getModel } from "@mariozechner/pi-ai"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AgentSession } from "../src/core/agent-session.js"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; +import { SessionManager } from "../src/core/session-manager.js"; +import { SettingsManager } from "../src/core/settings-manager.js"; + +// Mock stream that mimics AssistantMessageEventStream +class MockAssistantStream extends EventStream { + constructor() { + super( + (event) => event.type === "done" || event.type === "error", + (event) => { + if (event.type === "done") return event.message; + if (event.type === "error") return event.error; + throw new Error("Unexpected event type"); + }, + ); + } +} + +function createAssistantMessage(text: string): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "anthropic-messages", + provider: "anthropic", + model: "mock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; +} + +describe("AgentSession concurrent prompt guard", () => { + let session: AgentSession; + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `pi-concurrent-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(async () => { + if (session) { + session.dispose(); + } + if (tempDir && existsSync(tempDir)) { + rmSync(tempDir, { recursive: true }); + } + }); + + function createSession() { + const model = getModel("anthropic", "claude-sonnet-4-5")!; + let abortSignal: AbortSignal | undefined; + + // Use a stream function that responds to abort + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [], + }, + streamFn: (_model, _context, options) => { + abortSignal = options?.signal; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + const checkAbort = () => { + if (abortSignal?.aborted) { + stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); + } else { + setTimeout(checkAbort, 5); + } + }; + checkAbort(); + }); + return stream; + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + // Set a runtime API key so validation passes + authStorage.setRuntimeApiKey("anthropic", "test-key"); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + return session; + } + + it("should throw when prompt() called while streaming", async () => { + createSession(); + + // Start first prompt (don't await, it will block until abort) + const firstPrompt = session.prompt("First message"); + + // Wait a tick for isStreaming to be set + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify we're streaming + expect(session.isStreaming).toBe(true); + + // Second prompt should reject + await expect(session.prompt("Second message")).rejects.toThrow( + "Agent is already processing. Use steer() or followUp() to queue messages during streaming.", + ); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); // Ignore abort error + }); + + it("should allow steer() while streaming", async () => { + createSession(); + + // Start first prompt + const firstPrompt = session.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // steer should work while streaming + expect(() => session.steer("Steering message")).not.toThrow(); + expect(session.pendingMessageCount).toBe(1); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); + }); + + it("should allow followUp() while streaming", async () => { + createSession(); + + // Start first prompt + const firstPrompt = session.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // followUp should work while streaming + expect(() => session.followUp("Follow-up message")).not.toThrow(); + expect(session.pendingMessageCount).toBe(1); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); + }); + + it("should allow prompt() after previous completes", async () => { + // Create session with a stream that completes immediately + const model = getModel("anthropic", "claude-sonnet-4-5")!; + const agent = new Agent({ + getApiKey: () => "test-key", + initialState: { + model, + systemPrompt: "Test", + tools: [], + }, + streamFn: () => { + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "start", partial: createAssistantMessage("") }); + stream.push({ type: "done", reason: "stop", message: createAssistantMessage("Done") }); + }); + return stream; + }, + }); + + const sessionManager = SessionManager.inMemory(); + const settingsManager = SettingsManager.create(tempDir, tempDir); + const authStorage = new AuthStorage(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage, tempDir); + authStorage.setRuntimeApiKey("anthropic", "test-key"); + + session = new AgentSession({ + agent, + sessionManager, + settingsManager, + modelRegistry, + }); + + // First prompt completes + await session.prompt("First message"); + + // Should not be streaming anymore + expect(session.isStreaming).toBe(false); + + // Second prompt should work + await expect(session.prompt("Second message")).resolves.not.toThrow(); + }); +}); diff --git a/packages/coding-agent/test/compaction-hooks-example.test.ts b/packages/coding-agent/test/compaction-hooks-example.test.ts index deceaeb5..64e6cec6 100644 --- a/packages/coding-agent/test/compaction-hooks-example.test.ts +++ b/packages/coding-agent/test/compaction-hooks-example.test.ts @@ -11,17 +11,11 @@ describe("Documentation example", () => { const exampleHook = (pi: HookAPI) => { pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx) => { // All these should be accessible on the event - const { preparation, branchEntries, signal } = event; + const { preparation, branchEntries } = event; // sessionManager, modelRegistry, and model come from ctx - const { sessionManager, modelRegistry, model } = ctx; - const { - messagesToSummarize, - turnPrefixMessages, - tokensBefore, - firstKeptEntryId, - isSplitTurn, - previousSummary, - } = preparation; + const { sessionManager, modelRegistry } = ctx; + const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, isSplitTurn } = + preparation; // Verify types expect(Array.isArray(messagesToSummarize)).toBe(true); diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts index b0b65511..5d5a5130 100644 --- a/packages/coding-agent/test/compaction-hooks.test.ts +++ b/packages/coding-agent/test/compaction-hooks.test.ts @@ -21,6 +21,7 @@ import { ModelRegistry } from "../src/core/model-registry.js"; import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import { codingTools } from "../src/core/tools/index.js"; +import { theme } from "../src/modes/interactive/theme/theme.js"; const API_KEY = process.env.ANTHROPIC_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY; @@ -99,16 +100,26 @@ describe.skipIf(!API_KEY)("Compaction hooks", () => { const modelRegistry = new ModelRegistry(authStorage); hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry); - hookRunner.setUIContext( - { + hookRunner.initialize({ + getModel: () => session.model, + sendMessageHandler: async () => {}, + appendEntryHandler: async () => {}, + uiContext: { select: async () => undefined, confirm: async () => false, input: async () => undefined, notify: () => {}, - custom: () => ({ close: () => {}, requestRender: () => {} }), + setStatus: () => {}, + custom: async () => undefined as never, + setEditorText: () => {}, + getEditorText: () => "", + editor: async () => undefined, + get theme() { + return theme; + }, }, - false, - ); + hasUI: false, + }); session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts new file mode 100644 index 00000000..bab9085e --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -0,0 +1,57 @@ +import { Container } from "@mariozechner/pi-tui"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../src/modes/interactive/theme/theme.js"; + +function renderLastLine(container: Container, width = 120): string { + const last = container.children[container.children.length - 1]; + if (!last) return ""; + return last.render(width).join("\n"); +} + +describe("InteractiveMode.showStatus", () => { + beforeAll(() => { + // showStatus uses the global theme instance + initTheme("dark"); + }); + + test("coalesces immediately-sequential status messages", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + lastStatusSpacer: undefined, + lastStatusText: undefined, + }; + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); + expect(fakeThis.chatContainer.children).toHaveLength(2); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_ONE"); + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + // second status updates the previous line instead of appending + expect(fakeThis.chatContainer.children).toHaveLength(2); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); + expect(renderLastLine(fakeThis.chatContainer)).not.toContain("STATUS_ONE"); + }); + + test("appends a new status line if something else was added in between", () => { + const fakeThis: any = { + chatContainer: new Container(), + ui: { requestRender: vi.fn() }, + lastStatusSpacer: undefined, + lastStatusText: undefined, + }; + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_ONE"); + expect(fakeThis.chatContainer.children).toHaveLength(2); + + // Something else gets added to the chat in between status updates + fakeThis.chatContainer.addChild({ render: () => ["OTHER"], invalidate: () => {} }); + expect(fakeThis.chatContainer.children).toHaveLength(3); + + (InteractiveMode as any).prototype.showStatus.call(fakeThis, "STATUS_TWO"); + // adds spacer + text + expect(fakeThis.chatContainer.children).toHaveLength(5); + expect(renderLastLine(fakeThis.chatContainer)).toContain("STATUS_TWO"); + }); +}); diff --git a/packages/coding-agent/test/rpc.test.ts b/packages/coding-agent/test/rpc.test.ts index e0d6edac..240400b2 100644 --- a/packages/coding-agent/test/rpc.test.ts +++ b/packages/coding-agent/test/rpc.test.ts @@ -237,7 +237,7 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T expect(stats.assistantMessages).toBeGreaterThanOrEqual(1); }, 90000); - test("should reset session", async () => { + test("should create new session", async () => { await client.start(); // Send a prompt @@ -247,8 +247,8 @@ describe.skipIf(!process.env.ANTHROPIC_API_KEY && !process.env.ANTHROPIC_OAUTH_T let state = await client.getState(); expect(state.messageCount).toBeGreaterThan(0); - // Reset - await client.reset(); + // New session + await client.newSession(); // Verify messages cleared state = await client.getState(); diff --git a/packages/coding-agent/test/session-manager/save-entry.test.ts b/packages/coding-agent/test/session-manager/save-entry.test.ts index 2a618986..45015321 100644 --- a/packages/coding-agent/test/session-manager/save-entry.test.ts +++ b/packages/coding-agent/test/session-manager/save-entry.test.ts @@ -42,7 +42,7 @@ describe("SessionManager.saveCustomEntry", () => { expect(customEntry.parentId).toBe(msgId); // Tree structure should be correct - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(3); expect(path[0].id).toBe(msgId); expect(path[1].id).toBe(customId); diff --git a/packages/coding-agent/test/session-manager/tree-traversal.test.ts b/packages/coding-agent/test/session-manager/tree-traversal.test.ts index 5fe7610a..fe244710 100644 --- a/packages/coding-agent/test/session-manager/tree-traversal.test.ts +++ b/packages/coding-agent/test/session-manager/tree-traversal.test.ts @@ -122,14 +122,14 @@ describe("SessionManager append and tree traversal", () => { describe("getPath", () => { it("returns empty array for empty session", () => { const session = SessionManager.inMemory(); - expect(session.getPath()).toEqual([]); + expect(session.getBranch()).toEqual([]); }); it("returns single entry path", () => { const session = SessionManager.inMemory(); const id = session.appendMessage(userMsg("hello")); - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(1); expect(path[0].id).toBe(id); }); @@ -142,7 +142,7 @@ describe("SessionManager append and tree traversal", () => { const id3 = session.appendThinkingLevelChange("high"); const id4 = session.appendMessage(userMsg("3")); - const path = session.getPath(); + const path = session.getBranch(); expect(path).toHaveLength(4); expect(path.map((e) => e.id)).toEqual([id1, id2, id3, id4]); }); @@ -155,7 +155,7 @@ describe("SessionManager append and tree traversal", () => { const _id3 = session.appendMessage(userMsg("3")); const _id4 = session.appendMessage(assistantMsg("4")); - const path = session.getPath(id2); + const path = session.getBranch(id2); expect(path).toHaveLength(2); expect(path.map((e) => e.id)).toEqual([id1, id2]); }); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 8f18b9aa..d4601eb9 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -433,4 +433,18 @@ describe("edit tool CRLF handling", () => { }), ).rejects.toThrow(/Found 2 occurrences/); }); + + it("should preserve UTF-8 BOM after edit", async () => { + const testFile = join(testDir, "bom-test.txt"); + writeFileSync(testFile, "\uFEFFfirst\r\nsecond\r\nthird\r\n"); + + await editTool.execute("test-bom", { + path: testFile, + oldText: "second\n", + newText: "REPLACED\n", + }); + + const content = readFileSync(testFile, "utf-8"); + expect(content).toBe("\uFEFFfirst\r\nREPLACED\r\nthird\r\n"); + }); }); diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md index 996de8e6..7378f900 100644 --- a/packages/mom/CHANGELOG.md +++ b/packages/mom/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## [0.31.1] - 2026-01-02 + +## [0.31.0] - 2026-01-02 + ### Breaking Changes - `AgentTool` import moved from `@mariozechner/pi-ai` to `@mariozechner/pi-agent-core` diff --git a/packages/mom/package.json b/packages/mom/package.json index 81b162e6..ffe9b634 100644 --- a/packages/mom/package.json +++ b/packages/mom/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-mom", - "version": "0.30.2", + "version": "0.31.1", "description": "Slack bot that delegates messages to the pi coding agent", "type": "module", "bin": { @@ -20,9 +20,9 @@ }, "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.16", - "@mariozechner/pi-agent-core": "^0.30.2", - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-coding-agent": "^0.30.2", + "@mariozechner/pi-agent-core": "^0.31.1", + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-coding-agent": "^0.31.1", "@sinclair/typebox": "^0.34.0", "@slack/socket-mode": "^2.0.0", "@slack/web-api": "^7.0.0", diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 11f8a69c..f5a106da 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -495,11 +495,19 @@ export class MomSettingsManager { } // Compatibility methods for AgentSession - getQueueMode(): "all" | "one-at-a-time" { + getSteeringMode(): "all" | "one-at-a-time" { return "one-at-a-time"; // Mom processes one message at a time } - setQueueMode(_mode: "all" | "one-at-a-time"): void { + setSteeringMode(_mode: "all" | "one-at-a-time"): void { + // No-op for mom + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return "one-at-a-time"; // Mom processes one message at a time + } + + setFollowUpMode(_mode: "all" | "one-at-a-time"): void { // No-op for mom } diff --git a/packages/pods/package.json b/packages/pods/package.json index e80a7ee5..919d01b4 100644 --- a/packages/pods/package.json +++ b/packages/pods/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi", - "version": "0.30.2", + "version": "0.31.1", "description": "CLI tool for managing vLLM deployments on GPU pods", "type": "module", "bin": { @@ -33,7 +33,7 @@ "node": ">=20.0.0" }, "dependencies": { - "@mariozechner/pi-agent-core": "^0.30.2", + "@mariozechner/pi-agent-core": "^0.31.1", "chalk": "^5.5.0" }, "devDependencies": {} diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 4721ba30..20bd9445 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,22 @@ ## [Unreleased] +### Changed + +- Editor component now uses word wrapping instead of character-level wrapping for better readability ([#382](https://github.com/badlogic/pi-mono/pull/382) by [@nickseelert](https://github.com/nickseelert)) + +### Fixed + +- Shift+Space, Shift+Backspace, and Shift+Delete now work correctly in Kitty-protocol terminals (Kitty, WezTerm, etc.) instead of being silently ignored ([#411](https://github.com/badlogic/pi-mono/pull/411) by [@nathyong](https://github.com/nathyong)) + +## [0.31.1] - 2026-01-02 + +### Fixed + +- `visibleWidth()` now strips OSC 8 hyperlink sequences, fixing text wrapping for clickable links ([#396](https://github.com/badlogic/pi-mono/pull/396) by [@Cursivez](https://github.com/Cursivez)) + +## [0.31.0] - 2026-01-02 + ### Added - `isShiftCtrlO()` key detection function for Shift+Ctrl+O (Kitty protocol) @@ -12,11 +28,13 @@ ### Changed - README.md completely rewritten with accurate component documentation, theme interfaces, and examples -- Editor component now uses word wrapping instead of character-level wrapping for better readability +- `visibleWidth()` reimplemented with grapheme-based width calculation, 10x faster on Bun and ~15% faster on Node ([#369](https://github.com/badlogic/pi-mono/pull/369) by [@nathyong](https://github.com/nathyong)) ### Fixed - Markdown component now renders HTML tags as plain text instead of silently dropping them ([#359](https://github.com/badlogic/pi-mono/issues/359)) +- Crash in `visibleWidth()` and grapheme iteration when encountering undefined code points ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC)) +- ZWJ emoji sequences (rainbow flag, family, etc.) now render with correct width instead of being split into multiple characters ([#369](https://github.com/badlogic/pi-mono/pull/369) by [@nathyong](https://github.com/nathyong)) ## [0.29.0] - 2025-12-25 diff --git a/packages/tui/README.md b/packages/tui/README.md index bea046d4..c93a13b2 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -247,6 +247,26 @@ loader.setMessage("Still loading..."); loader.stop(); ``` +### CancellableLoader + +Extends Loader with Escape key handling and an AbortSignal for cancelling async operations. + +```typescript +const loader = new CancellableLoader( + tui, // TUI instance for render updates + (s) => chalk.cyan(s), // spinner color function + (s) => chalk.gray(s), // message color function + "Working..." // message +); +loader.onAbort = () => done(null); // Called when user presses Escape +doAsyncWork(loader.signal).then(done); +``` + +**Properties:** +- `signal: AbortSignal` - Aborted when user presses Escape +- `aborted: boolean` - Whether the loader was aborted +- `onAbort?: () => void` - Callback when user presses Escape + ### SelectList Interactive selection list with keyboard navigation. diff --git a/packages/tui/package.json b/packages/tui/package.json index 5e65e545..14f0fc2b 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-tui", - "version": "0.30.2", + "version": "0.31.1", "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "type": "module", "main": "dist/index.js", @@ -38,9 +38,9 @@ "dependencies": { "@types/mime-types": "^2.1.4", "chalk": "^5.5.0", + "get-east-asian-width": "^1.3.0", "marked": "^15.0.12", - "mime-types": "^3.0.1", - "string-width": "^8.1.0" + "mime-types": "^3.0.1" }, "devDependencies": { "@xterm/headless": "^5.5.0", diff --git a/packages/tui/src/components/cancellable-loader.ts b/packages/tui/src/components/cancellable-loader.ts new file mode 100644 index 00000000..8e2621da --- /dev/null +++ b/packages/tui/src/components/cancellable-loader.ts @@ -0,0 +1,39 @@ +import { isEscape } from "../keys.js"; +import { Loader } from "./loader.js"; + +/** + * Loader that can be cancelled with Escape. + * Extends Loader with an AbortSignal for cancelling async operations. + * + * @example + * const loader = new CancellableLoader(tui, cyan, dim, "Working..."); + * loader.onAbort = () => done(null); + * doWork(loader.signal).then(done); + */ +export class CancellableLoader extends Loader { + private abortController = new AbortController(); + + /** Called when user presses Escape */ + onAbort?: () => void; + + /** AbortSignal that is aborted when user presses Escape */ + get signal(): AbortSignal { + return this.abortController.signal; + } + + /** Whether the loader was aborted */ + get aborted(): boolean { + return this.abortController.signal.aborted; + } + + handleInput(data: string): void { + if (isEscape(data)) { + this.abortController.abort(); + this.onAbort?.(); + } + } + + dispose(): void { + this.stop(); + } +} diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 8f2bd7bb..c5765162 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -22,7 +22,10 @@ import { isEnter, isEscape, isHome, + isShiftBackspace, + isShiftDelete, isShiftEnter, + isShiftSpace, isTab, } from "../keys.js"; import type { Component } from "../tui.js"; @@ -637,8 +640,8 @@ export class Editor implements Component { this.onSubmit(result); } } - // Backspace - else if (isBackspace(data)) { + // Backspace (including Shift+Backspace) + else if (isBackspace(data) || isShiftBackspace(data)) { this.handleBackspace(); } // Line navigation shortcuts (Home/End keys) @@ -647,8 +650,8 @@ export class Editor implements Component { } else if (isEnd(data)) { this.moveToLineEnd(); } - // Forward delete (Fn+Backspace or Delete key) - else if (isDelete(data)) { + // Forward delete (Fn+Backspace or Delete key, including Shift+Delete) + else if (isDelete(data) || isShiftDelete(data)) { this.handleForwardDelete(); } // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) @@ -683,6 +686,10 @@ export class Editor implements Component { // Left this.moveCursor(0, -1); } + // Shift+Space - insert regular space (Kitty protocol sends escape sequence) + else if (isShiftSpace(data)) { + this.insertCharacter(" "); + } // Regular characters (printable characters and unicode, but not control characters) else if (data.charCodeAt(0) >= 32) { this.insertCharacter(data); diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index a96bd0eb..5a273824 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -1,6 +1,6 @@ import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js"; import type { Component } from "../tui.js"; -import { truncateToWidth, visibleWidth } from "../utils.js"; +import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js"; export interface SettingItem { /** Unique identifier for this setting */ @@ -123,7 +123,10 @@ export class SettingsList implements Component { const selectedItem = this.items[this.selectedIndex]; if (selectedItem?.description) { lines.push(""); - lines.push(this.theme.description(` ${truncateToWidth(selectedItem.description, width - 4, "")}`)); + const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4); + for (const line of wrappedDesc) { + lines.push(this.theme.description(` ${line}`)); + } } // Add hint diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 8fcff1e5..d5a16207 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -9,6 +9,7 @@ export { } from "./autocomplete.js"; // Components export { Box } from "./components/box.js"; +export { CancellableLoader } from "./components/cancellable-loader.js"; export { Editor, type EditorTheme } from "./components/editor.js"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.js"; export { Input } from "./components/input.js"; diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index da8cf699..58d57c4a 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -42,6 +42,7 @@ const CODEPOINTS = { escape: 27, tab: 9, enter: 13, + space: 32, backspace: 127, } as const; @@ -464,6 +465,15 @@ export function isBackspace(data: string): boolean { return data === "\x7f" || data === "\x08" || matchesKittySequence(data, CODEPOINTS.backspace, 0); } +/** + * Check if input matches Shift+Backspace (Kitty protocol). + * Returns true so caller can treat it as regular backspace. + * Ignores lock key bits. + */ +export function isShiftBackspace(data: string): boolean { + return matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.shift); +} + /** * Check if input matches Shift+Enter. * Ignores lock key bits. @@ -480,6 +490,15 @@ export function isAltEnter(data: string): boolean { return data === Keys.ALT_ENTER || data === "\x1b\r" || matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt); } +/** + * Check if input matches Shift+Space (Kitty protocol). + * Returns true so caller can insert a regular space. + * Ignores lock key bits. + */ +export function isShiftSpace(data: string): boolean { + return matchesKittySequence(data, CODEPOINTS.space, MODIFIERS.shift); +} + /** * Check if input matches Option/Alt+Left (word navigation). * Handles multiple formats including Kitty protocol. @@ -545,3 +564,12 @@ export function isEnd(data: string): boolean { export function isDelete(data: string): boolean { return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); } + +/** + * Check if input matches Shift+Delete (Kitty protocol). + * Returns true so caller can treat it as regular delete. + * Ignores lock key bits. + */ +export function isShiftDelete(data: string): boolean { + return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, MODIFIERS.shift); +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 9b27c12f..e484e2a5 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -26,6 +26,9 @@ export interface Terminal { clearLine(): void; // Clear current line clearFromCursor(): void; // Clear from cursor to end of screen clearScreen(): void; // Clear entire screen and move cursor to (0,0) + + // Title operations + setTitle(title: string): void; // Set terminal window title } /** @@ -127,4 +130,9 @@ export class ProcessTerminal implements Terminal { clearScreen(): void { process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } + + setTitle(title: string): void { + // OSC 0;title BEL - set terminal window title + process.stdout.write(`\x1b]0;${title}\x07`); + } } diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index 4c311443..1a7b7767 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -1,11 +1,135 @@ -import stringWidth from "string-width"; +import { eastAsianWidth } from "get-east-asian-width"; + +// Grapheme segmenter (shared instance) +const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); + +/** + * Get the shared grapheme segmenter instance. + */ +export function getSegmenter(): Intl.Segmenter { + return segmenter; +} + +/** + * Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji. + * This is a fast heuristic to avoid the expensive rgiEmojiRegex test. + * The tested Unicode blocks are deliberately broad to account for future + * Unicode additions. + */ +function couldBeEmoji(segment: string): boolean { + const cp = segment.codePointAt(0)!; + return ( + (cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph + (cp >= 0x2300 && cp <= 0x23ff) || // Misc technical + (cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats + (cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles + segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector) + segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.) + ); +} + +// Regexes for character classification (same as string-width library) +const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; +const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; + +// Cache for non-ASCII strings +const WIDTH_CACHE_SIZE = 512; +const widthCache = new Map(); + +/** + * Calculate the terminal width of a single grapheme cluster. + * Based on code from the string-width library, but includes a possible-emoji + * check to avoid running the RGI_Emoji regex unnecessarily. + */ +function graphemeWidth(segment: string): number { + // Zero-width clusters + if (zeroWidthRegex.test(segment)) { + return 0; + } + + // Emoji check with pre-filter + if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) { + return 2; + } + + // Get base visible codepoint + const base = segment.replace(leadingNonPrintingRegex, ""); + const cp = base.codePointAt(0); + if (cp === undefined) { + return 0; + } + + let width = eastAsianWidth(cp); + + // Trailing halfwidth/fullwidth forms + if (segment.length > 1) { + for (const char of segment.slice(1)) { + const c = char.codePointAt(0)!; + if (c >= 0xff00 && c <= 0xffef) { + width += eastAsianWidth(c); + } + } + } + + return width; +} /** * Calculate the visible width of a string in terminal columns. */ export function visibleWidth(str: string): number { - const normalized = str.replace(/\t/g, " "); - return stringWidth(normalized); + if (str.length === 0) { + return 0; + } + + // Fast path: pure ASCII printable + let isPureAscii = true; + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (code < 0x20 || code > 0x7e) { + isPureAscii = false; + break; + } + } + if (isPureAscii) { + return str.length; + } + + // Check cache + const cached = widthCache.get(str); + if (cached !== undefined) { + return cached; + } + + // Normalize: tabs to 3 spaces, strip ANSI escape codes + let clean = str; + if (str.includes("\t")) { + clean = clean.replace(/\t/g, " "); + } + if (clean.includes("\x1b")) { + // Strip SGR codes (\x1b[...m) and cursor codes (\x1b[...G/K/H/J) + clean = clean.replace(/\x1b\[[0-9;]*[mGKHJ]/g, ""); + // Strip OSC 8 hyperlinks: \x1b]8;;URL\x07 and \x1b]8;;\x07 + clean = clean.replace(/\x1b\]8;;[^\x07]*\x07/g, ""); + } + + // Calculate width + let width = 0; + for (const { segment } of segmenter.segment(clean)) { + width += graphemeWidth(segment); + } + + // Cache result + if (widthCache.size >= WIDTH_CACHE_SIZE) { + const firstKey = widthCache.keys().next().value; + if (firstKey !== undefined) { + widthCache.delete(firstKey); + } + } + widthCache.set(str, width); + + return width; } /** @@ -406,15 +530,6 @@ function wrapSingleLine(line: string, width: number): string[] { return wrapped.length > 0 ? wrapped : [""]; } -const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); - -/** - * Get the shared grapheme segmenter instance. - */ -export function getSegmenter(): Intl.Segmenter { - return segmenter; -} - const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/; /** @@ -472,6 +587,9 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s } const grapheme = seg.value; + // Skip empty graphemes to avoid issues with string-width calculation + if (!grapheme) continue; + const graphemeWidth = visibleWidth(grapheme); if (currentWidth + graphemeWidth > width) { @@ -576,6 +694,9 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis: string } const grapheme = seg.value; + // Skip empty graphemes to avoid issues with string-width calculation + if (!grapheme) continue; + const graphemeWidth = visibleWidth(grapheme); if (currentWidth + graphemeWidth > targetWidth) { diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 08673d3f..9b93ced3 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -86,6 +86,11 @@ export class VirtualTerminal implements Terminal { this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1) } + setTitle(title: string): void { + // OSC 0;title BEL - set terminal window title + this.xterm.write(`\x1b]0;${title}\x07`); + } + // Test-specific methods not in Terminal interface /** diff --git a/packages/web-ui/CHANGELOG.md b/packages/web-ui/CHANGELOG.md index 8a1c26e4..b80f5ec3 100644 --- a/packages/web-ui/CHANGELOG.md +++ b/packages/web-ui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +## [0.31.1] - 2026-01-02 + +## [0.31.0] - 2026-01-02 + ### Breaking Changes - **Agent class moved to `@mariozechner/pi-agent-core`**: The `Agent` class, `AgentState`, and related types are no longer exported from this package. Import them from `@mariozechner/pi-agent-core` instead. diff --git a/packages/web-ui/example/package.json b/packages/web-ui/example/package.json index 5b3d8e29..922e5f5e 100644 --- a/packages/web-ui/example/package.json +++ b/packages/web-ui/example/package.json @@ -1,6 +1,6 @@ { "name": "pi-web-ui-example", - "version": "1.18.2", + "version": "1.19.1", "private": true, "type": "module", "scripts": { diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 4a93f409..ae7e2ed0 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -346,7 +346,7 @@ const renderApp = () => { onClick: () => { // Demo: Inject custom message (will appear on next agent run) if (agent) { - agent.queueMessage( + agent.steer( createSystemNotification( "This is a custom message! It appears in the UI but is never sent to the LLM.", ), diff --git a/packages/web-ui/package.json b/packages/web-ui/package.json index c732b97a..056bbf1f 100644 --- a/packages/web-ui/package.json +++ b/packages/web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@mariozechner/pi-web-ui", - "version": "0.30.2", + "version": "0.31.1", "description": "Reusable web UI components for AI chat interfaces powered by @mariozechner/pi-ai", "type": "module", "main": "dist/index.js", @@ -18,8 +18,8 @@ }, "dependencies": { "@lmstudio/sdk": "^1.5.0", - "@mariozechner/pi-ai": "^0.30.2", - "@mariozechner/pi-tui": "^0.30.2", + "@mariozechner/pi-ai": "^0.31.1", + "@mariozechner/pi-tui": "^0.31.1", "docx-preview": "^0.3.7", "jszip": "^3.10.1", "lucide": "^0.544.0", diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..3ae3823f --- /dev/null +++ b/todo.md @@ -0,0 +1,6 @@ +# Export HTML TODOs + +- [x] "Ctrl+T toggle thinking ยท Ctrl+O toggle tools" font size is not the same as all the other font sizes +- [ ] System prompt doesn't show included AGENTS.md, skills. See `packages/coding-agent/src/core/system-prompt.ts`. Can only be done if we export live from a session, not via `--export` CLI flag +- [x] "Available Tools" has no newline after it +- [x] `read` tool has too much vertical spacing between tool call header and tool result, and also with tool bottom border (was white-space: pre-wrap on .tool-output preserving template literal whitespace)