mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
remove
This commit is contained in:
parent
0973c1cbc5
commit
88e7883051
188 changed files with 64 additions and 27581 deletions
|
|
@ -7,8 +7,6 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t
|
||||||
- packages/tui/README.md
|
- packages/tui/README.md
|
||||||
- packages/agent/README.md
|
- packages/agent/README.md
|
||||||
- packages/coding-agent/README.md
|
- packages/coding-agent/README.md
|
||||||
- packages/mom/README.md
|
|
||||||
- packages/pods/README.md
|
|
||||||
- packages/web-ui/README.md
|
- packages/web-ui/README.md
|
||||||
|
|
||||||
## Code Quality
|
## Code Quality
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
> **Looking for the pi coding agent?** See **[packages/coding-agent](packages/coding-agent)** for installation and usage.
|
> **Looking for the pi coding agent?** See **[packages/coding-agent](packages/coding-agent)** for installation and usage.
|
||||||
|
|
||||||
Tools for building AI agents and managing LLM deployments.
|
Tools for building AI agents and running the pi coding agent.
|
||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
|
|
@ -26,10 +26,8 @@ Tools for building AI agents and managing LLM deployments.
|
||||||
| **[@mariozechner/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
| **[@mariozechner/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
|
||||||
| **[@mariozechner/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
| **[@mariozechner/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
|
||||||
| **[@mariozechner/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
| **[@mariozechner/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
|
||||||
| **[@mariozechner/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent |
|
|
||||||
| **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
| **[@mariozechner/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
|
||||||
| **[@mariozechner/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
|
| **[@mariozechner/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
|
||||||
| **[@mariozechner/pi-pods](packages/pods)** | CLI for managing vLLM deployments on GPU pods |
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,12 @@
|
||||||
"includes": [
|
"includes": [
|
||||||
"packages/*/src/**/*.ts",
|
"packages/*/src/**/*.ts",
|
||||||
"packages/*/test/**/*.ts",
|
"packages/*/test/**/*.ts",
|
||||||
"packages/coding-agent/examples/**/*.ts",
|
|
||||||
"packages/web-ui/src/**/*.ts",
|
"packages/web-ui/src/**/*.ts",
|
||||||
"packages/web-ui/example/**/*.ts",
|
"packages/web-ui/example/**/*.ts",
|
||||||
"!**/node_modules/**/*",
|
"!**/node_modules/**/*",
|
||||||
"!**/test-sessions.ts",
|
"!**/test-sessions.ts",
|
||||||
"!**/models.generated.ts",
|
"!**/models.generated.ts",
|
||||||
"!packages/web-ui/src/app.css",
|
"!packages/web-ui/src/app.css",
|
||||||
"!packages/mom/data/**/*",
|
|
||||||
"!!**/node_modules"
|
"!!**/node_modules"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
146
package-lock.json
generated
146
package-lock.json
generated
|
|
@ -9,11 +9,7 @@
|
||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/web-ui/example",
|
"packages/web-ui/example"
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-qwen-cli"
|
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/jiti": "^2.6.5",
|
"@mariozechner/jiti": "^2.6.5",
|
||||||
|
|
@ -34,35 +30,6 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
|
||||||
"version": "0.0.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.16.tgz",
|
|
||||||
"integrity": "sha512-I2Us7dRvwCJkqcImrqP7NWrV5kHmKX7AumFDnznCjMd0hB5ZUzg9Is9SlxrMzHiUY2RndEaRLXCOJrUt8JnE4w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@pondwader/socks5-server": "^1.0.10",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"shell-quote": "^1.8.3",
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"srt": "dist/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@anthropic-ai/sandbox-runtime/node_modules/zod": {
|
|
||||||
"version": "3.25.76",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
"node_modules/@anthropic-ai/sdk": {
|
||||||
"version": "0.73.0",
|
"version": "0.73.0",
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
|
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
|
||||||
|
|
@ -1544,10 +1511,6 @@
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@local/pi-runtime-daemon": {
|
|
||||||
"resolved": "packages/pi-runtime-daemon",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@mariozechner/clipboard": {
|
"node_modules/@mariozechner/clipboard": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.2.tgz",
|
||||||
|
|
@ -1782,10 +1745,6 @@
|
||||||
"node": ">= 20"
|
"node": ">= 20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/pi": {
|
|
||||||
"resolved": "packages/pods",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@mariozechner/pi-agent-core": {
|
"node_modules/@mariozechner/pi-agent-core": {
|
||||||
"resolved": "packages/agent",
|
"resolved": "packages/agent",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -1798,10 +1757,6 @@
|
||||||
"resolved": "packages/coding-agent",
|
"resolved": "packages/coding-agent",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@mariozechner/pi-mom": {
|
|
||||||
"resolved": "packages/mom",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@mariozechner/pi-tui": {
|
"node_modules/@mariozechner/pi-tui": {
|
||||||
"resolved": "packages/tui",
|
"resolved": "packages/tui",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -2430,12 +2385,6 @@
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@pondwader/socks5-server": {
|
|
||||||
"version": "1.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
|
||||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@preact/signals-core": {
|
"node_modules/@preact/signals-core": {
|
||||||
"version": "1.13.0",
|
"version": "1.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.13.0.tgz",
|
||||||
|
|
@ -3940,21 +3889,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/lodash": {
|
|
||||||
"version": "4.17.24",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
|
|
||||||
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/lodash-es": {
|
|
||||||
"version": "4.17.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/lodash": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/mime-types": {
|
"node_modules/@types/mime-types": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.4.tgz",
|
||||||
|
|
@ -4781,15 +4715,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "12.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
|
||||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
|
|
@ -4925,15 +4850,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/croner": {
|
|
||||||
"version": "9.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz",
|
|
||||||
"integrity": "sha512-p9nwwR4qyT5W996vBZhdvBCnMhicY5ytZkR4D1Xj0wuTDEiMnjwR57Q3RXYY/s0EpX6Ay3vgIcfaR+ewGHsi+g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "6.0.6",
|
"version": "6.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
|
||||||
|
|
@ -6581,12 +6497,6 @@
|
||||||
"@types/trusted-types": "^2.0.2"
|
"@types/trusted-types": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
|
||||||
"version": "4.17.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
|
|
||||||
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/long": {
|
"node_modules/long": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
|
@ -7144,22 +7054,6 @@
|
||||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pi-extension-custom-provider-anthropic": {
|
|
||||||
"resolved": "packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/pi-extension-custom-provider-gitlab-duo": {
|
|
||||||
"resolved": "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/pi-extension-custom-provider-qwen-cli": {
|
|
||||||
"resolved": "packages/coding-agent/examples/extensions/custom-provider-qwen-cli",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/pi-extension-with-deps": {
|
|
||||||
"resolved": "packages/coding-agent/examples/extensions/with-deps",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/pi-memory-md": {
|
"node_modules/pi-memory-md": {
|
||||||
"resolved": "packages/pi-memory-md",
|
"resolved": "packages/pi-memory-md",
|
||||||
"link": true
|
"link": true
|
||||||
|
|
@ -7650,6 +7544,7 @@
|
||||||
"version": "1.8.3",
|
"version": "1.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
|
|
@ -8957,21 +8852,25 @@
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
"packages/coding-agent/examples/extensions/custom-provider-anthropic": {
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
"name": "pi-extension-custom-provider-anthropic",
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.52.0"
|
"@anthropic-ai/sdk": "^0.52.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": {
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
"name": "pi-extension-custom-provider-gitlab-duo",
|
||||||
"version": "1.7.2"
|
"version": "1.7.2",
|
||||||
|
"extraneous": true
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-qwen-cli": {
|
"packages/coding-agent/examples/extensions/custom-provider-qwen-cli": {
|
||||||
"name": "pi-extension-custom-provider-qwen-cli",
|
"name": "pi-extension-custom-provider-qwen-cli",
|
||||||
"version": "1.6.2"
|
"version": "1.6.2",
|
||||||
|
"extraneous": true
|
||||||
},
|
},
|
||||||
"packages/coding-agent/examples/extensions/with-deps": {
|
"packages/coding-agent/examples/extensions/with-deps": {
|
||||||
"name": "pi-extension-with-deps",
|
"name": "pi-extension-with-deps",
|
||||||
"version": "1.20.2",
|
"version": "1.20.2",
|
||||||
|
"extraneous": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
|
|
@ -8979,15 +8878,6 @@
|
||||||
"@types/ms": "^2.1.0"
|
"@types/ms": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/coding-agent/node_modules/@anthropic-ai/sdk": {
|
|
||||||
"version": "0.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz",
|
|
||||||
"integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"anthropic-ai-sdk": "bin/cli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/coding-agent/node_modules/@types/node": {
|
"packages/coding-agent/node_modules/@types/node": {
|
||||||
"version": "24.11.1",
|
"version": "24.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.1.tgz",
|
||||||
|
|
@ -9008,6 +8898,7 @@
|
||||||
"packages/mom": {
|
"packages/mom": {
|
||||||
"name": "@mariozechner/pi-mom",
|
"name": "@mariozechner/pi-mom",
|
||||||
"version": "0.56.2",
|
"version": "0.56.2",
|
||||||
|
"extraneous": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
"@anthropic-ai/sandbox-runtime": "^0.0.16",
|
||||||
|
|
@ -9033,23 +8924,6 @@
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/mom/node_modules/@types/node": {
|
|
||||||
"version": "24.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.1.tgz",
|
|
||||||
"integrity": "sha512-MOw3rIVR4djfMH7ft9ZJLPViaJwkZvMfrzumElas79IwMUEl8ykkuQmgL9MAMz7vO8G3vuz9b7Gu+keYZx7Xrw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~7.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"packages/mom/node_modules/undici-types": {
|
|
||||||
"version": "7.16.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
|
||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"packages/pi-channels": {
|
"packages/pi-channels": {
|
||||||
"name": "@e9n/pi-channels",
|
"name": "@e9n/pi-channels",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|
@ -9131,6 +9005,7 @@
|
||||||
"packages/pi-runtime-daemon": {
|
"packages/pi-runtime-daemon": {
|
||||||
"name": "@local/pi-runtime-daemon",
|
"name": "@local/pi-runtime-daemon",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"extraneous": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"pi-runtime-daemon": "bin/pi-runtime-daemon.mjs"
|
"pi-runtime-daemon": "bin/pi-runtime-daemon.mjs"
|
||||||
|
|
@ -9408,6 +9283,7 @@
|
||||||
"packages/pods": {
|
"packages/pods": {
|
||||||
"name": "@mariozechner/pi",
|
"name": "@mariozechner/pi",
|
||||||
"version": "0.56.2",
|
"version": "0.56.2",
|
||||||
|
"extraneous": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mariozechner/pi-agent-core": "^0.56.2",
|
"@mariozechner/pi-agent-core": "^0.56.2",
|
||||||
|
|
|
||||||
10
package.json
10
package.json
|
|
@ -12,16 +12,12 @@
|
||||||
},
|
},
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/web-ui/example",
|
"packages/web-ui/example"
|
||||||
"packages/coding-agent/examples/extensions/with-deps",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-anthropic",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-gitlab-duo",
|
|
||||||
"packages/coding-agent/examples/extensions/custom-provider-qwen-cli"
|
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "npm run clean --workspaces",
|
"clean": "npm run clean --workspaces",
|
||||||
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../mom && npm run build && cd ../web-ui && npm run build && cd ../pods && npm run build",
|
"build": "cd packages/tui && npm run build && cd ../ai && npm run build && cd ../agent && npm run build && cd ../coding-agent && npm run build && cd ../web-ui && npm run build",
|
||||||
"dev": "concurrently --names \"ai,agent,coding-agent,mom,web-ui,tui\" --prefix-colors \"cyan,yellow,red,white,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/mom && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
|
"dev": "concurrently --names \"ai,agent,coding-agent,web-ui,tui\" --prefix-colors \"cyan,yellow,red,green,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/web-ui && npm run dev\" \"cd packages/tui && npm run dev\"",
|
||||||
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
|
"dev:tsc": "concurrently --names \"ai,web-ui\" --prefix-colors \"cyan,green\" \"cd packages/ai && npm run dev:tsc\" \"cd packages/web-ui && npm run dev:tsc\"",
|
||||||
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
|
"check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:browser-smoke && cd packages/web-ui && npm run check",
|
||||||
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
|
"check:browser-smoke": "sh -c 'esbuild scripts/browser-smoke-entry.ts --bundle --platform=browser --format=esm --log-limit=0 --outfile=/tmp/pi-browser-smoke.js > /tmp/pi-browser-smoke-errors.log 2>&1 || { echo \"Browser smoke check failed. See /tmp/pi-browser-smoke-errors.log\"; exit 1; }'",
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
- Games while waiting (yes, Doom runs)
|
- Games while waiting (yes, Doom runs)
|
||||||
- ...anything you can dream up
|
- ...anything you can dream up
|
||||||
|
|
||||||
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md) and [examples/extensions/](examples/extensions/).
|
Place in `~/.pi/agent/extensions/`, `.pi/extensions/`, or a [pi package](#pi-packages) to share with others. See [docs/extensions.md](docs/extensions.md).
|
||||||
|
|
||||||
### Themes
|
### Themes
|
||||||
|
|
||||||
|
|
@ -385,7 +385,7 @@ const { session } = await createAgentSession({
|
||||||
await session.prompt("What files are in the current directory?");
|
await session.prompt("What files are in the current directory?");
|
||||||
```
|
```
|
||||||
|
|
||||||
See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/).
|
See [docs/sdk.md](docs/sdk.md).
|
||||||
|
|
||||||
### RPC Mode
|
### RPC Mode
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,8 @@ Extensions can register custom model providers via `pi.registerProvider()`. This
|
||||||
- **OAuth/SSO** - Add authentication flows for enterprise providers
|
- **OAuth/SSO** - Add authentication flows for enterprise providers
|
||||||
- **Custom APIs** - Implement streaming for non-standard LLM APIs
|
- **Custom APIs** - Implement streaming for non-standard LLM APIs
|
||||||
|
|
||||||
## Example Extensions
|
|
||||||
|
|
||||||
See these complete provider examples:
|
|
||||||
|
|
||||||
- [`examples/extensions/custom-provider-anthropic/`](../examples/extensions/custom-provider-anthropic/)
|
|
||||||
- [`examples/extensions/custom-provider-gitlab-duo/`](../examples/extensions/custom-provider-gitlab-duo/)
|
|
||||||
- [`examples/extensions/custom-provider-qwen-cli/`](../examples/extensions/custom-provider-qwen-cli/)
|
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Example Extensions](#example-extensions)
|
|
||||||
- [Quick Reference](#quick-reference)
|
- [Quick Reference](#quick-reference)
|
||||||
- [Override Existing Provider](#override-existing-provider)
|
- [Override Existing Provider](#override-existing-provider)
|
||||||
- [Register New Provider](#register-new-provider)
|
- [Register New Provider](#register-new-provider)
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ Or set `GOOGLE_APPLICATION_CREDENTIALS` to a service account key file.
|
||||||
|
|
||||||
**Via models.json:** Add Ollama, LM Studio, vLLM, or any provider that speaks a supported API (OpenAI Completions, OpenAI Responses, Anthropic Messages, Google Generative AI). See [models.md](models.md).
|
**Via models.json:** Add Ollama, LM Studio, vLLM, or any provider that speaks a supported API (OpenAI Completions, OpenAI Responses, Anthropic Messages, Google Generative AI). See [models.md](models.md).
|
||||||
|
|
||||||
**Via extensions:** For providers that need custom API implementations or OAuth flows, create an extension. See [custom-provider.md](custom-provider.md) and [examples/extensions/custom-provider-gitlab-duo](../examples/extensions/custom-provider-gitlab-duo/).
|
**Via extensions:** For providers that need custom API implementations or OAuth flows, create an extension. See [custom-provider.md](custom-provider.md).
|
||||||
|
|
||||||
## Resolution Order
|
## Resolution Order
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ The SDK provides programmatic access to pi's agent capabilities. Use it to embed
|
||||||
- Build custom tools that spawn sub-agents
|
- Build custom tools that spawn sub-agents
|
||||||
- Test agent behavior programmatically
|
- Test agent behavior programmatically
|
||||||
|
|
||||||
See [examples/sdk/](../examples/sdk/) for working examples from minimal to full control.
|
See the sections below for end-to-end SDK patterns and the exported APIs you can compose.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|
@ -319,8 +319,6 @@ If no model is provided:
|
||||||
2. Uses default from settings
|
2. Uses default from settings
|
||||||
3. Falls back to first available model
|
3. Falls back to first available model
|
||||||
|
|
||||||
> See [examples/sdk/02-custom-model.ts](../examples/sdk/02-custom-model.ts)
|
|
||||||
|
|
||||||
### API Keys and OAuth
|
### API Keys and OAuth
|
||||||
|
|
||||||
API key resolution priority (handled by AuthStorage):
|
API key resolution priority (handled by AuthStorage):
|
||||||
|
|
@ -359,8 +357,6 @@ const { session } = await createAgentSession({
|
||||||
const simpleRegistry = new ModelRegistry(authStorage);
|
const simpleRegistry = new ModelRegistry(authStorage);
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/09-api-keys-and-oauth.ts](../examples/sdk/09-api-keys-and-oauth.ts)
|
|
||||||
|
|
||||||
### System Prompt
|
### System Prompt
|
||||||
|
|
||||||
Use a `ResourceLoader` to override the system prompt:
|
Use a `ResourceLoader` to override the system prompt:
|
||||||
|
|
@ -376,8 +372,6 @@ await loader.reload();
|
||||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/03-custom-prompt.ts](../examples/sdk/03-custom-prompt.ts)
|
|
||||||
|
|
||||||
### Tools
|
### Tools
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -438,8 +432,6 @@ const { session } = await createAgentSession({
|
||||||
**When you must use factories:**
|
**When you must use factories:**
|
||||||
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
|
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
|
||||||
|
|
||||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
|
||||||
|
|
||||||
### Custom Tools
|
### Custom Tools
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -468,8 +460,6 @@ const { session } = await createAgentSession({
|
||||||
|
|
||||||
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
|
Custom tools passed via `customTools` are combined with extension-registered tools. Extensions loaded by the ResourceLoader can also register tools via `pi.registerTool()`.
|
||||||
|
|
||||||
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
|
|
||||||
|
|
||||||
### Extensions
|
### Extensions
|
||||||
|
|
||||||
Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
|
Extensions are loaded by the `ResourceLoader`. `DefaultResourceLoader` discovers extensions from `~/.pi/agent/extensions/`, `.pi/extensions/`, and settings.json extension sources.
|
||||||
|
|
@ -508,8 +498,6 @@ await loader.reload();
|
||||||
eventBus.on("my-extension:status", (data) => console.log(data));
|
eventBus.on("my-extension:status", (data) => console.log(data));
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/06-extensions.ts](../examples/sdk/06-extensions.ts) and [docs/extensions.md](extensions.md)
|
|
||||||
|
|
||||||
### Skills
|
### Skills
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -538,8 +526,6 @@ await loader.reload();
|
||||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/04-skills.ts](../examples/sdk/04-skills.ts)
|
|
||||||
|
|
||||||
### Context Files
|
### Context Files
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -558,8 +544,6 @@ await loader.reload();
|
||||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/07-context-files.ts](../examples/sdk/07-context-files.ts)
|
|
||||||
|
|
||||||
### Slash Commands
|
### Slash Commands
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -587,8 +571,6 @@ await loader.reload();
|
||||||
const { session } = await createAgentSession({ resourceLoader: loader });
|
const { session } = await createAgentSession({ resourceLoader: loader });
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/08-prompt-templates.ts](../examples/sdk/08-prompt-templates.ts)
|
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
|
|
||||||
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
|
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
|
||||||
|
|
@ -660,8 +642,6 @@ sm.branchWithSummary(id, "Summary..."); // Branch with context summary
|
||||||
sm.createBranchedSession(leafId); // Extract path to new file
|
sm.createBranchedSession(leafId); // Extract path to new file
|
||||||
```
|
```
|
||||||
|
|
||||||
> See [examples/sdk/11-sessions.ts](../examples/sdk/11-sessions.ts) and [docs/session.md](session.md)
|
|
||||||
|
|
||||||
### Settings Management
|
### Settings Management
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
@ -711,8 +691,6 @@ Project overrides global. Nested objects merge keys. Setters modify global setti
|
||||||
- Call `await settingsManager.flush()` when you need a durability boundary (for example, before process exit or before asserting file contents in tests).
|
- Call `await settingsManager.flush()` when you need a durability boundary (for example, before process exit or before asserting file contents in tests).
|
||||||
- `SettingsManager` does not print settings I/O errors. Use `settingsManager.drainErrors()` and report them in your app layer.
|
- `SettingsManager` does not print settings I/O errors. Use `settingsManager.drainErrors()` and report them in your app layer.
|
||||||
|
|
||||||
> See [examples/sdk/10-settings.ts](../examples/sdk/10-settings.ts)
|
|
||||||
|
|
||||||
## ResourceLoader
|
## ResourceLoader
|
||||||
|
|
||||||
Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
|
Use `DefaultResourceLoader` to discover extensions, skills, prompts, themes, and context files.
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
# Examples
|
|
||||||
|
|
||||||
Example code for pi-coding-agent SDK and extensions.
|
|
||||||
|
|
||||||
## Directories
|
|
||||||
|
|
||||||
### [sdk/](sdk/)
|
|
||||||
Programmatic usage via `createAgentSession()`. Shows how to customize models, prompts, tools, extensions, and session management.
|
|
||||||
|
|
||||||
### [extensions/](extensions/)
|
|
||||||
Example extensions demonstrating:
|
|
||||||
- Lifecycle event handlers (tool interception, safety gates, context modifications)
|
|
||||||
- Custom tools (todo lists, questions, subagents, output truncation)
|
|
||||||
- Commands and keyboard shortcuts
|
|
||||||
- Custom UI (footers, headers, editors, overlays)
|
|
||||||
- Git integration (checkpoints, auto-commit)
|
|
||||||
- System prompt modifications and custom compaction
|
|
||||||
- External integrations (SSH, file watchers, system theme sync)
|
|
||||||
- Custom providers (Anthropic with custom streaming, GitLab Duo)
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- [SDK Reference](sdk/README.md)
|
|
||||||
- [Extensions Documentation](../docs/extensions.md)
|
|
||||||
- [Skills Documentation](../docs/skills.md)
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
# Extension Examples
|
|
||||||
|
|
||||||
Example extensions for pi-coding-agent.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Load an extension with --extension flag
|
|
||||||
pi --extension examples/extensions/permission-gate.ts
|
|
||||||
|
|
||||||
# Or copy to extensions directory for auto-discovery
|
|
||||||
cp permission-gate.ts ~/.pi/agent/extensions/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Lifecycle & Safety
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
|
|
||||||
| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
|
|
||||||
| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, fork) |
|
|
||||||
| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
|
|
||||||
| `sandbox/` | OS-level sandboxing using `@anthropic-ai/sandbox-runtime` with per-project config |
|
|
||||||
|
|
||||||
### Custom Tools
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `todo.ts` | Todo list tool + `/todos` command with custom rendering and state persistence |
|
|
||||||
| `hello.ts` | Minimal custom tool example |
|
|
||||||
| `question.ts` | Demonstrates `ctx.ui.select()` for asking the user questions with custom UI |
|
|
||||||
| `questionnaire.ts` | Multi-question input with tab bar navigation between questions |
|
|
||||||
| `tool-override.ts` | Override built-in tools (e.g., add logging/access control to `read`) |
|
|
||||||
| `dynamic-tools.ts` | Register tools after startup (`session_start`) and at runtime via command, with prompt snippets and tool-specific prompt guidelines |
|
|
||||||
| `built-in-tool-renderer.ts` | Custom compact rendering for built-in tools (read, bash, edit, write) while keeping original behavior |
|
|
||||||
| `minimal-mode.ts` | Override built-in tool rendering for minimal display (only tool calls, no output in collapsed mode) |
|
|
||||||
| `truncated-tool.ts` | Wraps ripgrep with proper output truncation (50KB/2000 lines) |
|
|
||||||
| `antigravity-image-gen.ts` | Generate images via Google Antigravity with optional save-to-disk modes |
|
|
||||||
| `ssh.ts` | Delegate all tools to a remote machine via SSH using pluggable operations |
|
|
||||||
| `subagent/` | Delegate tasks to specialized subagents with isolated context windows |
|
|
||||||
|
|
||||||
### Commands & UI
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `preset.ts` | Named presets for model, thinking level, tools, and instructions via `--preset` flag and `/preset` command |
|
|
||||||
| `plan-mode/` | Claude Code-style plan mode for read-only exploration with `/plan` command and step tracking |
|
|
||||||
| `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence |
|
|
||||||
| `handoff.ts` | Transfer context to a new focused session via `/handoff <goal>` |
|
|
||||||
| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
|
|
||||||
| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
|
|
||||||
| `widget-placement.ts` | Shows widgets above and below the editor via `ctx.ui.setWidget()` placement |
|
|
||||||
| `model-status.ts` | Shows model changes in status bar via `model_select` hook |
|
|
||||||
| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
|
|
||||||
| `send-user-message.ts` | Demonstrates `pi.sendUserMessage()` for sending user messages from extensions |
|
|
||||||
| `timed-confirm.ts` | Demonstrates AbortSignal for auto-dismissing `ctx.ui.confirm()` and `ctx.ui.select()` dialogs |
|
|
||||||
| `rpc-demo.ts` | Exercises all RPC-supported extension UI methods; pair with [`examples/rpc-extension-ui.ts`](../rpc-extension-ui.ts) |
|
|
||||||
| `modal-editor.ts` | Custom vim-like modal editor via `ctx.ui.setEditorComponent()` |
|
|
||||||
| `rainbow-editor.ts` | Animated rainbow text effect via custom editor |
|
|
||||||
| `notify.ts` | Desktop notifications via OSC 777 when agent finishes (Ghostty, iTerm2, WezTerm) |
|
|
||||||
| `titlebar-spinner.ts` | Braille spinner animation in terminal title while the agent is working |
|
|
||||||
| `summarize.ts` | Summarize conversation with GPT-5.2 and show in transient UI |
|
|
||||||
| `custom-footer.ts` | Custom footer with git branch and token stats via `ctx.ui.setFooter()` |
|
|
||||||
| `custom-header.ts` | Custom header via `ctx.ui.setHeader()` |
|
|
||||||
| `overlay-test.ts` | Test overlay compositing with inline text inputs and edge cases |
|
|
||||||
| `overlay-qa-tests.ts` | Comprehensive overlay QA tests: anchors, margins, stacking, overflow, animation |
|
|
||||||
| `doom-overlay/` | DOOM game running as an overlay at 35 FPS (demonstrates real-time game rendering) |
|
|
||||||
| `shutdown-command.ts` | Adds `/quit` command demonstrating `ctx.shutdown()` |
|
|
||||||
| `reload-runtime.ts` | Adds `/reload-runtime` and `reload_runtime` tool showing safe reload flow |
|
|
||||||
| `interactive-shell.ts` | Run interactive commands (vim, htop) with full terminal via `user_bash` hook |
|
|
||||||
| `inline-bash.ts` | Expands `!{command}` patterns in prompts via `input` event transformation |
|
|
||||||
|
|
||||||
### Git Integration
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on fork |
|
|
||||||
| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
|
|
||||||
|
|
||||||
### System Prompt & Compaction
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
|
|
||||||
| `claude-rules.ts` | Scans `.claude/rules/` folder and lists rules in system prompt |
|
|
||||||
| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
|
|
||||||
| `trigger-compact.ts` | Triggers compaction when context usage exceeds 100k tokens and adds `/trigger-compact` command |
|
|
||||||
|
|
||||||
### System Integration
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `mac-system-theme.ts` | Syncs pi theme with macOS dark/light mode |
|
|
||||||
|
|
||||||
### Resources
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `dynamic-resources/` | Loads skills, prompts, and themes using `resources_discover` |
|
|
||||||
|
|
||||||
### Messages & Communication
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `message-renderer.ts` | Custom message rendering with colors and expandable details via `registerMessageRenderer` |
|
|
||||||
| `event-bus.ts` | Inter-extension communication via `pi.events` |
|
|
||||||
|
|
||||||
### Session Metadata
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `session-name.ts` | Name sessions for the session selector via `setSessionName` |
|
|
||||||
| `bookmark.ts` | Bookmark entries with labels for `/tree` navigation via `setLabel` |
|
|
||||||
|
|
||||||
### Custom Providers
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `custom-provider-anthropic/` | Custom Anthropic provider with OAuth support and custom streaming implementation |
|
|
||||||
| `custom-provider-gitlab-duo/` | GitLab Duo provider using pi-ai's built-in Anthropic/OpenAI streaming via proxy |
|
|
||||||
| `custom-provider-qwen-cli/` | Qwen CLI provider with OAuth device flow and OpenAI-compatible models |
|
|
||||||
|
|
||||||
### External Dependencies
|
|
||||||
|
|
||||||
| Extension | Description |
|
|
||||||
|-----------|-------------|
|
|
||||||
| `with-deps/` | Extension with its own package.json and dependencies (demonstrates jiti module resolution) |
|
|
||||||
| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
|
|
||||||
|
|
||||||
## Writing Extensions
|
|
||||||
|
|
||||||
See [docs/extensions.md](../../docs/extensions.md) for full documentation.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Subscribe to lifecycle events
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
|
||||||
if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
|
|
||||||
const ok = await ctx.ui.confirm("Dangerous!", "Allow rm -rf?");
|
|
||||||
if (!ok) return { block: true, reason: "Blocked by user" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register custom tools
|
|
||||||
pi.registerTool({
|
|
||||||
name: "greet",
|
|
||||||
label: "Greeting",
|
|
||||||
description: "Generate a greeting",
|
|
||||||
parameters: Type.Object({
|
|
||||||
name: Type.String({ description: "Name to greet" }),
|
|
||||||
}),
|
|
||||||
async execute(toolCallId, params, onUpdate, ctx, signal) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Hello, ${params.name}!` }],
|
|
||||||
details: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register commands
|
|
||||||
pi.registerCommand("hello", {
|
|
||||||
description: "Say hello",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
ctx.ui.notify("Hello!", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Patterns
|
|
||||||
|
|
||||||
**Use StringEnum for string parameters** (required for Google API compatibility):
|
|
||||||
```typescript
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
|
||||||
|
|
||||||
// Good
|
|
||||||
action: StringEnum(["list", "add"] as const)
|
|
||||||
|
|
||||||
// Bad - doesn't work with Google
|
|
||||||
action: Type.Union([Type.Literal("list"), Type.Literal("add")])
|
|
||||||
```
|
|
||||||
|
|
||||||
**State persistence via details:**
|
|
||||||
```typescript
|
|
||||||
// Store state in tool result details for proper forking support
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Done" }],
|
|
||||||
details: { todos: [...todos], nextId }, // Persisted in session
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reconstruct on session events
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
for (const entry of ctx.sessionManager.getBranch()) {
|
|
||||||
if (entry.type === "message" && entry.message.toolName === "my_tool") {
|
|
||||||
const details = entry.message.details;
|
|
||||||
// Reconstruct state from details
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
@ -1,415 +0,0 @@
|
||||||
/**
|
|
||||||
* Antigravity Image Generation
|
|
||||||
*
|
|
||||||
* Generates images via Google Antigravity's image models (gemini-3-pro-image, imagen-3).
|
|
||||||
* Returns images as tool result attachments for inline terminal rendering.
|
|
||||||
* Requires OAuth login via /login for google-antigravity.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* "Generate an image of a sunset over mountains"
|
|
||||||
* "Create a 16:9 wallpaper of a cyberpunk city"
|
|
||||||
*
|
|
||||||
* Save modes (tool param, env var, or config file):
|
|
||||||
* save=none - Don't save to disk (default)
|
|
||||||
* save=project - Save to <repo>/.pi/generated-images/
|
|
||||||
* save=global - Save to ~/.pi/agent/generated-images/
|
|
||||||
* save=custom - Save to saveDir param or PI_IMAGE_SAVE_DIR
|
|
||||||
*
|
|
||||||
* Environment variables:
|
|
||||||
* PI_IMAGE_SAVE_MODE - Default save mode (none|project|global|custom)
|
|
||||||
* PI_IMAGE_SAVE_DIR - Directory for custom save mode
|
|
||||||
*
|
|
||||||
* Config files (project overrides global):
|
|
||||||
* ~/.pi/agent/extensions/antigravity-image-gen.json
|
|
||||||
* <repo>/.pi/extensions/antigravity-image-gen.json
|
|
||||||
* Example: { "save": "global" }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { type Static, Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
const PROVIDER = "google-antigravity";
|
|
||||||
|
|
||||||
const ASPECT_RATIOS = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"] as const;
|
|
||||||
|
|
||||||
type AspectRatio = (typeof ASPECT_RATIOS)[number];
|
|
||||||
|
|
||||||
const DEFAULT_MODEL = "gemini-3-pro-image";
|
|
||||||
const DEFAULT_ASPECT_RATIO: AspectRatio = "1:1";
|
|
||||||
const DEFAULT_SAVE_MODE = "none";
|
|
||||||
|
|
||||||
const SAVE_MODES = ["none", "project", "global", "custom"] as const;
|
|
||||||
type SaveMode = (typeof SAVE_MODES)[number];
|
|
||||||
|
|
||||||
const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
|
|
||||||
|
|
||||||
const DEFAULT_ANTIGRAVITY_VERSION = "1.18.3";
|
|
||||||
|
|
||||||
const ANTIGRAVITY_HEADERS = {
|
|
||||||
"User-Agent": `antigravity/${process.env.PI_AI_ANTIGRAVITY_VERSION || DEFAULT_ANTIGRAVITY_VERSION} darwin/arm64`,
|
|
||||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
||||||
"Client-Metadata": JSON.stringify({
|
|
||||||
ideType: "IDE_UNSPECIFIED",
|
|
||||||
platform: "PLATFORM_UNSPECIFIED",
|
|
||||||
pluginType: "GEMINI",
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const IMAGE_SYSTEM_INSTRUCTION =
|
|
||||||
"You are an AI image generator. Generate images based on user descriptions. Focus on creating high-quality, visually appealing images that match the user's request.";
|
|
||||||
|
|
||||||
const TOOL_PARAMS = Type.Object({
|
|
||||||
prompt: Type.String({ description: "Image description." }),
|
|
||||||
model: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: "Image model id (e.g., gemini-3-pro-image, imagen-3). Default: gemini-3-pro-image.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
aspectRatio: Type.Optional(StringEnum(ASPECT_RATIOS)),
|
|
||||||
save: Type.Optional(StringEnum(SAVE_MODES)),
|
|
||||||
saveDir: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: "Directory to save image when save=custom. Defaults to PI_IMAGE_SAVE_DIR if set.",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
type ToolParams = Static<typeof TOOL_PARAMS>;
|
|
||||||
|
|
||||||
interface CloudCodeAssistRequest {
|
|
||||||
project: string;
|
|
||||||
model: string;
|
|
||||||
request: {
|
|
||||||
contents: Content[];
|
|
||||||
sessionId?: string;
|
|
||||||
systemInstruction?: { role?: string; parts: { text: string }[] };
|
|
||||||
generationConfig?: {
|
|
||||||
maxOutputTokens?: number;
|
|
||||||
temperature?: number;
|
|
||||||
imageConfig?: { aspectRatio?: string };
|
|
||||||
candidateCount?: number;
|
|
||||||
};
|
|
||||||
safetySettings?: Array<{ category: string; threshold: string }>;
|
|
||||||
};
|
|
||||||
requestType?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
requestId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CloudCodeAssistResponseChunk {
|
|
||||||
response?: {
|
|
||||||
candidates?: Array<{
|
|
||||||
content?: {
|
|
||||||
role: string;
|
|
||||||
parts?: Array<{
|
|
||||||
text?: string;
|
|
||||||
inlineData?: {
|
|
||||||
mimeType?: string;
|
|
||||||
data?: string;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}>;
|
|
||||||
usageMetadata?: {
|
|
||||||
promptTokenCount?: number;
|
|
||||||
candidatesTokenCount?: number;
|
|
||||||
thoughtsTokenCount?: number;
|
|
||||||
totalTokenCount?: number;
|
|
||||||
cachedContentTokenCount?: number;
|
|
||||||
};
|
|
||||||
modelVersion?: string;
|
|
||||||
responseId?: string;
|
|
||||||
};
|
|
||||||
traceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Content {
|
|
||||||
role: "user" | "model";
|
|
||||||
parts: Part[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Part {
|
|
||||||
text?: string;
|
|
||||||
inlineData?: {
|
|
||||||
mimeType?: string;
|
|
||||||
data?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParsedCredentials {
|
|
||||||
accessToken: string;
|
|
||||||
projectId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExtensionConfig {
|
|
||||||
save?: SaveMode;
|
|
||||||
saveDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SaveConfig {
|
|
||||||
mode: SaveMode;
|
|
||||||
outputDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOAuthCredentials(raw: string): ParsedCredentials {
|
|
||||||
let parsed: { token?: string; projectId?: string };
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(raw) as { token?: string; projectId?: string };
|
|
||||||
} catch {
|
|
||||||
throw new Error("Invalid Google OAuth credentials. Run /login to re-authenticate.");
|
|
||||||
}
|
|
||||||
if (!parsed.token || !parsed.projectId) {
|
|
||||||
throw new Error("Missing token or projectId in Google OAuth credentials. Run /login.");
|
|
||||||
}
|
|
||||||
return { accessToken: parsed.token, projectId: parsed.projectId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function readConfigFile(path: string): ExtensionConfig {
|
|
||||||
if (!existsSync(path)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = readFileSync(path, "utf-8");
|
|
||||||
const parsed = JSON.parse(content) as ExtensionConfig;
|
|
||||||
return parsed ?? {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadConfig(cwd: string): ExtensionConfig {
|
|
||||||
const globalConfig = readConfigFile(join(homedir(), ".pi", "agent", "extensions", "antigravity-image-gen.json"));
|
|
||||||
const projectConfig = readConfigFile(join(cwd, ".pi", "extensions", "antigravity-image-gen.json"));
|
|
||||||
return { ...globalConfig, ...projectConfig };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSaveConfig(params: ToolParams, cwd: string): SaveConfig {
|
|
||||||
const config = loadConfig(cwd);
|
|
||||||
const envMode = (process.env.PI_IMAGE_SAVE_MODE || "").toLowerCase();
|
|
||||||
const paramMode = params.save;
|
|
||||||
const mode = (paramMode || envMode || config.save || DEFAULT_SAVE_MODE) as SaveMode;
|
|
||||||
|
|
||||||
if (!SAVE_MODES.includes(mode)) {
|
|
||||||
return { mode: DEFAULT_SAVE_MODE as SaveMode };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "project") {
|
|
||||||
return { mode, outputDir: join(cwd, ".pi", "generated-images") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "global") {
|
|
||||||
return { mode, outputDir: join(homedir(), ".pi", "agent", "generated-images") };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === "custom") {
|
|
||||||
const dir = params.saveDir || process.env.PI_IMAGE_SAVE_DIR || config.saveDir;
|
|
||||||
if (!dir || !dir.trim()) {
|
|
||||||
throw new Error("save=custom requires saveDir or PI_IMAGE_SAVE_DIR.");
|
|
||||||
}
|
|
||||||
return { mode, outputDir: dir };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { mode };
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageExtension(mimeType: string): string {
|
|
||||||
const lower = mimeType.toLowerCase();
|
|
||||||
if (lower.includes("jpeg") || lower.includes("jpg")) return "jpg";
|
|
||||||
if (lower.includes("gif")) return "gif";
|
|
||||||
if (lower.includes("webp")) return "webp";
|
|
||||||
return "png";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveImage(base64Data: string, mimeType: string, outputDir: string): Promise<string> {
|
|
||||||
await mkdir(outputDir, { recursive: true });
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
||||||
const ext = imageExtension(mimeType);
|
|
||||||
const filename = `image-${timestamp}-${randomUUID().slice(0, 8)}.${ext}`;
|
|
||||||
const filePath = join(outputDir, filename);
|
|
||||||
await writeFile(filePath, Buffer.from(base64Data, "base64"));
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequest(prompt: string, model: string, projectId: string, aspectRatio: string): CloudCodeAssistRequest {
|
|
||||||
return {
|
|
||||||
project: projectId,
|
|
||||||
model,
|
|
||||||
request: {
|
|
||||||
contents: [
|
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
parts: [{ text: prompt }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
systemInstruction: {
|
|
||||||
parts: [{ text: IMAGE_SYSTEM_INSTRUCTION }],
|
|
||||||
},
|
|
||||||
generationConfig: {
|
|
||||||
imageConfig: { aspectRatio },
|
|
||||||
candidateCount: 1,
|
|
||||||
},
|
|
||||||
safetySettings: [
|
|
||||||
{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_ONLY_HIGH" },
|
|
||||||
{ category: "HARM_CATEGORY_HATE_SPEECH", threshold: "BLOCK_ONLY_HIGH" },
|
|
||||||
{ category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "BLOCK_ONLY_HIGH" },
|
|
||||||
{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "BLOCK_ONLY_HIGH" },
|
|
||||||
{ category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "BLOCK_ONLY_HIGH" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
requestType: "agent",
|
|
||||||
requestId: `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
|
|
||||||
userAgent: "antigravity",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function parseSseForImage(
|
|
||||||
response: Response,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<{ image: { data: string; mimeType: string }; text: string[] }> {
|
|
||||||
if (!response.body) {
|
|
||||||
throw new Error("No response body");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
const textParts: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new Error("Request was aborted");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith("data:")) continue;
|
|
||||||
const jsonStr = line.slice(5).trim();
|
|
||||||
if (!jsonStr) continue;
|
|
||||||
|
|
||||||
let chunk: CloudCodeAssistResponseChunk;
|
|
||||||
try {
|
|
||||||
chunk = JSON.parse(jsonStr) as CloudCodeAssistResponseChunk;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = chunk.response;
|
|
||||||
if (!responseData?.candidates) continue;
|
|
||||||
|
|
||||||
for (const candidate of responseData.candidates) {
|
|
||||||
const parts = candidate.content?.parts;
|
|
||||||
if (!parts) continue;
|
|
||||||
for (const part of parts) {
|
|
||||||
if (part.text) {
|
|
||||||
textParts.push(part.text);
|
|
||||||
}
|
|
||||||
if (part.inlineData?.data) {
|
|
||||||
await reader.cancel();
|
|
||||||
return {
|
|
||||||
image: {
|
|
||||||
data: part.inlineData.data,
|
|
||||||
mimeType: part.inlineData.mimeType || "image/png",
|
|
||||||
},
|
|
||||||
text: textParts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.releaseLock();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("No image data returned by the model");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCredentials(ctx: {
|
|
||||||
modelRegistry: { getApiKeyForProvider: (provider: string) => Promise<string | undefined> };
|
|
||||||
}): Promise<ParsedCredentials> {
|
|
||||||
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(PROVIDER);
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error("Missing Google Antigravity OAuth credentials. Run /login for google-antigravity.");
|
|
||||||
}
|
|
||||||
return parseOAuthCredentials(apiKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function antigravityImageGen(pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "generate_image",
|
|
||||||
label: "Generate image",
|
|
||||||
description:
|
|
||||||
"Generate an image via Google Antigravity image models. Returns the image as a tool result attachment. Optional saving via save=project|global|custom|none, or PI_IMAGE_SAVE_MODE/PI_IMAGE_SAVE_DIR.",
|
|
||||||
parameters: TOOL_PARAMS,
|
|
||||||
async execute(_toolCallId, params: ToolParams, signal, onUpdate, ctx) {
|
|
||||||
const { accessToken, projectId } = await getCredentials(ctx);
|
|
||||||
const model = params.model || DEFAULT_MODEL;
|
|
||||||
const aspectRatio = params.aspectRatio || DEFAULT_ASPECT_RATIO;
|
|
||||||
|
|
||||||
const requestBody = buildRequest(params.prompt, model, projectId, aspectRatio);
|
|
||||||
onUpdate?.({
|
|
||||||
content: [{ type: "text", text: `Requesting image from ${PROVIDER}/${model}...` }],
|
|
||||||
details: { provider: PROVIDER, model, aspectRatio },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "text/event-stream",
|
|
||||||
...ANTIGRAVITY_HEADERS,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
throw new Error(`Image request failed (${response.status}): ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = await parseSseForImage(response, signal);
|
|
||||||
const saveConfig = resolveSaveConfig(params, ctx.cwd);
|
|
||||||
let savedPath: string | undefined;
|
|
||||||
let saveError: string | undefined;
|
|
||||||
if (saveConfig.mode !== "none" && saveConfig.outputDir) {
|
|
||||||
try {
|
|
||||||
savedPath = await saveImage(parsed.image.data, parsed.image.mimeType, saveConfig.outputDir);
|
|
||||||
} catch (error) {
|
|
||||||
saveError = error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const summaryParts = [`Generated image via ${PROVIDER}/${model}.`, `Aspect ratio: ${aspectRatio}.`];
|
|
||||||
if (savedPath) {
|
|
||||||
summaryParts.push(`Saved image to: ${savedPath}`);
|
|
||||||
} else if (saveError) {
|
|
||||||
summaryParts.push(`Failed to save image: ${saveError}`);
|
|
||||||
}
|
|
||||||
if (parsed.text.length > 0) {
|
|
||||||
summaryParts.push(`Model notes: ${parsed.text.join(" ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: summaryParts.join(" ") },
|
|
||||||
{ type: "image", data: parsed.image.data, mimeType: parsed.image.mimeType },
|
|
||||||
],
|
|
||||||
details: { provider: PROVIDER, model, aspectRatio, savedPath, saveMode: saveConfig.mode },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
/**
|
|
||||||
* Auto-Commit on Exit Extension
|
|
||||||
*
|
|
||||||
* Automatically commits changes when the agent exits.
|
|
||||||
* Uses the last assistant message to generate a commit message.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_shutdown", async (_event, ctx) => {
|
|
||||||
// Check for uncommitted changes
|
|
||||||
const { stdout: status, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
||||||
|
|
||||||
if (code !== 0 || status.trim().length === 0) {
|
|
||||||
// Not a git repo or no changes
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last assistant message for commit context
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
let lastAssistantText = "";
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
||||||
const content = entry.message.content;
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
lastAssistantText = content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a simple commit message
|
|
||||||
const firstLine = lastAssistantText.split("\n")[0] || "Work in progress";
|
|
||||||
const commitMessage = `[pi] ${firstLine.slice(0, 50)}${firstLine.length > 50 ? "..." : ""}`;
|
|
||||||
|
|
||||||
// Stage and commit
|
|
||||||
await pi.exec("git", ["add", "-A"]);
|
|
||||||
const { code: commitCode } = await pi.exec("git", ["commit", "-m", commitMessage]);
|
|
||||||
|
|
||||||
if (commitCode === 0 && ctx.hasUI) {
|
|
||||||
ctx.ui.notify(`Auto-committed: ${commitMessage}`, "info");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Bash Spawn Hook Example
|
|
||||||
*
|
|
||||||
* Adjusts command, cwd, and env before execution.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./bash-spawn-hook.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { createBashTool } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
const bashTool = createBashTool(cwd, {
|
|
||||||
spawnHook: ({ command, cwd, env }) => ({
|
|
||||||
command: `source ~/.profile\n${command}`,
|
|
||||||
cwd,
|
|
||||||
env: { ...env, PI_SPAWN_HOOK: "1" },
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...bashTool,
|
|
||||||
execute: async (id, params, signal, onUpdate, _ctx) => {
|
|
||||||
return bashTool.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/**
|
|
||||||
* Entry bookmarking example.
|
|
||||||
*
|
|
||||||
* Shows setLabel to mark entries with labels for easy navigation in /tree.
|
|
||||||
* Labels appear in the tree view and help you find important points.
|
|
||||||
*
|
|
||||||
* Usage: /bookmark [label] - bookmark the last assistant message
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("bookmark", {
|
|
||||||
description: "Bookmark last message (usage: /bookmark [label])",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
const label = args.trim() || `bookmark-${Date.now()}`;
|
|
||||||
|
|
||||||
// Find the last assistant message entry
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
||||||
pi.setLabel(entry.id, label);
|
|
||||||
ctx.ui.notify(`Bookmarked as: ${label}`, "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.ui.notify("No assistant message to bookmark", "warning");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove bookmark
|
|
||||||
pi.registerCommand("unbookmark", {
|
|
||||||
description: "Remove bookmark from last labeled entry",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i];
|
|
||||||
const label = ctx.sessionManager.getLabel(entry.id);
|
|
||||||
if (label) {
|
|
||||||
pi.setLabel(entry.id, undefined);
|
|
||||||
ctx.ui.notify(`Removed bookmark: ${label}`, "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.ui.notify("No bookmarked entry found", "warning");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,246 +0,0 @@
|
||||||
/**
|
|
||||||
* Built-in Tool Renderer Example - Custom rendering for built-in tools
|
|
||||||
*
|
|
||||||
* Demonstrates how to override the rendering of built-in tools (read, bash,
|
|
||||||
* edit, write) without changing their behavior. Each tool is re-registered
|
|
||||||
* with the same name, delegating execution to the original implementation
|
|
||||||
* while providing compact custom renderCall/renderResult functions.
|
|
||||||
*
|
|
||||||
* This is useful for users who prefer more concise tool output, or who want
|
|
||||||
* to highlight specific information (e.g., showing only the diff stats for
|
|
||||||
* edit, or just the exit code for bash).
|
|
||||||
*
|
|
||||||
* How it works:
|
|
||||||
* - registerTool() with the same name as a built-in replaces it entirely
|
|
||||||
* - We create instances of the original tools via createReadTool(), etc.
|
|
||||||
* and delegate execute() to them
|
|
||||||
* - renderCall() controls what's shown when the tool is invoked
|
|
||||||
* - renderResult() controls what's shown after execution completes
|
|
||||||
* - The `expanded` flag in renderResult indicates whether the user has
|
|
||||||
* toggled the tool output open (via ctrl+e or clicking)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./built-in-tool-renderer.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { BashToolDetails, EditToolDetails, ExtensionAPI, ReadToolDetails } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { createBashTool, createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const cwd = process.cwd();
|
|
||||||
|
|
||||||
// --- Read tool: show path and line count ---
|
|
||||||
const originalRead = createReadTool(cwd);
|
|
||||||
pi.registerTool({
|
|
||||||
name: "read",
|
|
||||||
label: "read",
|
|
||||||
description: originalRead.description,
|
|
||||||
parameters: originalRead.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate) {
|
|
||||||
return originalRead.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("read "));
|
|
||||||
text += theme.fg("accent", args.path);
|
|
||||||
if (args.offset || args.limit) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (args.offset) parts.push(`offset=${args.offset}`);
|
|
||||||
if (args.limit) parts.push(`limit=${args.limit}`);
|
|
||||||
text += theme.fg("dim", ` (${parts.join(", ")})`);
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded, isPartial }, theme) {
|
|
||||||
if (isPartial) return new Text(theme.fg("warning", "Reading..."), 0, 0);
|
|
||||||
|
|
||||||
const details = result.details as ReadToolDetails | undefined;
|
|
||||||
const content = result.content[0];
|
|
||||||
|
|
||||||
if (content?.type === "image") {
|
|
||||||
return new Text(theme.fg("success", "Image loaded"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content?.type !== "text") {
|
|
||||||
return new Text(theme.fg("error", "No content"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lineCount = content.text.split("\n").length;
|
|
||||||
let text = theme.fg("success", `${lineCount} lines`);
|
|
||||||
|
|
||||||
if (details?.truncation?.truncated) {
|
|
||||||
text += theme.fg("warning", ` (truncated from ${details.truncation.totalLines})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
const lines = content.text.split("\n").slice(0, 15);
|
|
||||||
for (const line of lines) {
|
|
||||||
text += `\n${theme.fg("dim", line)}`;
|
|
||||||
}
|
|
||||||
if (lineCount > 15) {
|
|
||||||
text += `\n${theme.fg("muted", `... ${lineCount - 15} more lines`)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Bash tool: show command and exit code ---
|
|
||||||
const originalBash = createBashTool(cwd);
|
|
||||||
pi.registerTool({
|
|
||||||
name: "bash",
|
|
||||||
label: "bash",
|
|
||||||
description: originalBash.description,
|
|
||||||
parameters: originalBash.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate) {
|
|
||||||
return originalBash.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("$ "));
|
|
||||||
const cmd = args.command.length > 80 ? `${args.command.slice(0, 77)}...` : args.command;
|
|
||||||
text += theme.fg("accent", cmd);
|
|
||||||
if (args.timeout) {
|
|
||||||
text += theme.fg("dim", ` (timeout: ${args.timeout}s)`);
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded, isPartial }, theme) {
|
|
||||||
if (isPartial) return new Text(theme.fg("warning", "Running..."), 0, 0);
|
|
||||||
|
|
||||||
const details = result.details as BashToolDetails | undefined;
|
|
||||||
const content = result.content[0];
|
|
||||||
const output = content?.type === "text" ? content.text : "";
|
|
||||||
|
|
||||||
const exitMatch = output.match(/exit code: (\d+)/);
|
|
||||||
const exitCode = exitMatch ? parseInt(exitMatch[1], 10) : null;
|
|
||||||
const lineCount = output.split("\n").filter((l) => l.trim()).length;
|
|
||||||
|
|
||||||
let text = "";
|
|
||||||
if (exitCode === 0 || exitCode === null) {
|
|
||||||
text += theme.fg("success", "done");
|
|
||||||
} else {
|
|
||||||
text += theme.fg("error", `exit ${exitCode}`);
|
|
||||||
}
|
|
||||||
text += theme.fg("dim", ` (${lineCount} lines)`);
|
|
||||||
|
|
||||||
if (details?.truncation?.truncated) {
|
|
||||||
text += theme.fg("warning", " [truncated]");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
const lines = output.split("\n").slice(0, 20);
|
|
||||||
for (const line of lines) {
|
|
||||||
text += `\n${theme.fg("dim", line)}`;
|
|
||||||
}
|
|
||||||
if (output.split("\n").length > 20) {
|
|
||||||
text += `\n${theme.fg("muted", "... more output")}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Edit tool: show path and diff stats ---
|
|
||||||
const originalEdit = createEditTool(cwd);
|
|
||||||
pi.registerTool({
|
|
||||||
name: "edit",
|
|
||||||
label: "edit",
|
|
||||||
description: originalEdit.description,
|
|
||||||
parameters: originalEdit.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate) {
|
|
||||||
return originalEdit.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("edit "));
|
|
||||||
text += theme.fg("accent", args.path);
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded, isPartial }, theme) {
|
|
||||||
if (isPartial) return new Text(theme.fg("warning", "Editing..."), 0, 0);
|
|
||||||
|
|
||||||
const details = result.details as EditToolDetails | undefined;
|
|
||||||
const content = result.content[0];
|
|
||||||
|
|
||||||
if (content?.type === "text" && content.text.startsWith("Error")) {
|
|
||||||
return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!details?.diff) {
|
|
||||||
return new Text(theme.fg("success", "Applied"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count additions and removals from the diff
|
|
||||||
const diffLines = details.diff.split("\n");
|
|
||||||
let additions = 0;
|
|
||||||
let removals = 0;
|
|
||||||
for (const line of diffLines) {
|
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) additions++;
|
|
||||||
if (line.startsWith("-") && !line.startsWith("---")) removals++;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = theme.fg("success", `+${additions}`);
|
|
||||||
text += theme.fg("dim", " / ");
|
|
||||||
text += theme.fg("error", `-${removals}`);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
for (const line of diffLines.slice(0, 30)) {
|
|
||||||
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
||||||
text += `\n${theme.fg("success", line)}`;
|
|
||||||
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
||||||
text += `\n${theme.fg("error", line)}`;
|
|
||||||
} else {
|
|
||||||
text += `\n${theme.fg("dim", line)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (diffLines.length > 30) {
|
|
||||||
text += `\n${theme.fg("muted", `... ${diffLines.length - 30} more diff lines`)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Write tool: show path and size ---
|
|
||||||
const originalWrite = createWriteTool(cwd);
|
|
||||||
pi.registerTool({
|
|
||||||
name: "write",
|
|
||||||
label: "write",
|
|
||||||
description: originalWrite.description,
|
|
||||||
parameters: originalWrite.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate) {
|
|
||||||
return originalWrite.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("write "));
|
|
||||||
text += theme.fg("accent", args.path);
|
|
||||||
const lineCount = args.content.split("\n").length;
|
|
||||||
text += theme.fg("dim", ` (${lineCount} lines)`);
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { isPartial }, theme) {
|
|
||||||
if (isPartial) return new Text(theme.fg("warning", "Writing..."), 0, 0);
|
|
||||||
|
|
||||||
const content = result.content[0];
|
|
||||||
if (content?.type === "text" && content.text.startsWith("Error")) {
|
|
||||||
return new Text(theme.fg("error", content.text.split("\n")[0]), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(theme.fg("success", "Written"), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
/**
|
|
||||||
* Claude Rules Extension
|
|
||||||
*
|
|
||||||
* Scans the project's .claude/rules/ folder for rule files and lists them
|
|
||||||
* in the system prompt. The agent can then use the read tool to load
|
|
||||||
* specific rules when needed.
|
|
||||||
*
|
|
||||||
* Best practices for .claude/rules/:
|
|
||||||
* - Keep rules focused: Each file should cover one topic (e.g., testing.md, api-design.md)
|
|
||||||
* - Use descriptive filenames: The filename should indicate what the rules cover
|
|
||||||
* - Use conditional rules sparingly: Only add paths frontmatter when rules truly apply to specific file types
|
|
||||||
* - Organize with subdirectories: Group related rules (e.g., frontend/, backend/)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
|
||||||
* 2. Create .claude/rules/ folder in your project root
|
|
||||||
* 3. Add .md files with your rules
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively find all .md files in a directory
|
|
||||||
*/
|
|
||||||
function findMarkdownFiles(dir: string, basePath: string = ""): string[] {
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name;
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
results.push(...findMarkdownFiles(path.join(dir, entry.name), relativePath));
|
|
||||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
||||||
results.push(relativePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function claudeRulesExtension(pi: ExtensionAPI) {
|
|
||||||
let ruleFiles: string[] = [];
|
|
||||||
let rulesDir: string = "";
|
|
||||||
|
|
||||||
// Scan for rules on session start
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
rulesDir = path.join(ctx.cwd, ".claude", "rules");
|
|
||||||
ruleFiles = findMarkdownFiles(rulesDir);
|
|
||||||
|
|
||||||
if (ruleFiles.length > 0) {
|
|
||||||
ctx.ui.notify(`Found ${ruleFiles.length} rule(s) in .claude/rules/`, "info");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append available rules to system prompt
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
|
||||||
if (ruleFiles.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rulesList = ruleFiles.map((f) => `- .claude/rules/${f}`).join("\n");
|
|
||||||
|
|
||||||
return {
|
|
||||||
systemPrompt:
|
|
||||||
event.systemPrompt +
|
|
||||||
`
|
|
||||||
|
|
||||||
## Project Rules
|
|
||||||
|
|
||||||
The following project rules are available in .claude/rules/:
|
|
||||||
|
|
||||||
${rulesList}
|
|
||||||
|
|
||||||
When working on tasks related to these rules, use the read tool to load the relevant rule files for guidance.
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
/**
|
|
||||||
* Commands Extension
|
|
||||||
*
|
|
||||||
* Demonstrates the pi.getCommands() API by providing a /commands command
|
|
||||||
* that lists all available slash commands in the current session.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
|
||||||
* 2. Use /commands to see available commands
|
|
||||||
* 3. Use /commands extensions to filter by source
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, SlashCommandInfo } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function commandsExtension(pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("commands", {
|
|
||||||
description: "List available slash commands",
|
|
||||||
getArgumentCompletions: (prefix) => {
|
|
||||||
const sources = ["extension", "prompt", "skill"];
|
|
||||||
const filtered = sources.filter((s) => s.startsWith(prefix));
|
|
||||||
return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
|
|
||||||
},
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
const commands = pi.getCommands();
|
|
||||||
const sourceFilter = args.trim() as "extension" | "prompt" | "skill" | "";
|
|
||||||
|
|
||||||
// Filter by source if specified
|
|
||||||
const filtered = sourceFilter ? commands.filter((c) => c.source === sourceFilter) : commands;
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
ctx.ui.notify(sourceFilter ? `No ${sourceFilter} commands found` : "No commands found", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build selection items grouped by source
|
|
||||||
const formatCommand = (cmd: SlashCommandInfo): string => {
|
|
||||||
const desc = cmd.description ? ` - ${cmd.description}` : "";
|
|
||||||
return `/${cmd.name}${desc}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const items: string[] = [];
|
|
||||||
const sources: Array<{ key: "extension" | "prompt" | "skill"; label: string }> = [
|
|
||||||
{ key: "extension", label: "Extensions" },
|
|
||||||
{ key: "prompt", label: "Prompts" },
|
|
||||||
{ key: "skill", label: "Skills" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const { key, label } of sources) {
|
|
||||||
const cmds = filtered.filter((c) => c.source === key);
|
|
||||||
if (cmds.length > 0) {
|
|
||||||
items.push(`--- ${label} ---`);
|
|
||||||
items.push(...cmds.map(formatCommand));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show in a selector (user can scroll and see all commands)
|
|
||||||
const selected = await ctx.ui.select("Available Commands", items);
|
|
||||||
|
|
||||||
// If user selected a command (not a header), offer to show its path
|
|
||||||
if (selected && !selected.startsWith("---")) {
|
|
||||||
const cmdName = selected.split(" - ")[0].slice(1); // Remove leading /
|
|
||||||
const cmd = commands.find((c) => c.name === cmdName);
|
|
||||||
if (cmd?.path) {
|
|
||||||
const showPath = await ctx.ui.confirm(cmd.name, `View source path?\n${cmd.path}`);
|
|
||||||
if (showPath) {
|
|
||||||
ctx.ui.notify(cmd.path, "info");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
/**
|
|
||||||
* Confirm Destructive Actions Extension
|
|
||||||
*
|
|
||||||
* Prompts for confirmation before destructive session actions (clear, switch, branch).
|
|
||||||
* Demonstrates how to cancel session events using the before_* events.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
|
|
||||||
if (!ctx.hasUI) return;
|
|
||||||
|
|
||||||
if (event.reason === "new") {
|
|
||||||
const confirmed = await ctx.ui.confirm(
|
|
||||||
"Clear session?",
|
|
||||||
"This will delete all messages in the current session.",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
ctx.ui.notify("Clear cancelled", "info");
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// reason === "resume" - check if there are unsaved changes (messages since last assistant response)
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
const hasUnsavedWork = entries.some(
|
|
||||||
(e): e is SessionMessageEntry => e.type === "message" && e.message.role === "user",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasUnsavedWork) {
|
|
||||||
const confirmed = await ctx.ui.confirm(
|
|
||||||
"Switch session?",
|
|
||||||
"You have messages in the current session. Switch anyway?",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
ctx.ui.notify("Switch cancelled", "info");
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_before_fork", async (event, ctx) => {
|
|
||||||
if (!ctx.hasUI) return;
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select(`Fork from entry ${event.entryId.slice(0, 8)}?`, [
|
|
||||||
"Yes, create fork",
|
|
||||||
"No, stay in current session",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (choice !== "Yes, create fork") {
|
|
||||||
ctx.ui.notify("Fork cancelled", "info");
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom Compaction Extension
|
|
||||||
*
|
|
||||||
* Replaces the default compaction behavior with a full summary of the entire context.
|
|
||||||
* Instead of keeping the last 20k tokens of conversation turns, this extension:
|
|
||||||
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
|
||||||
* 2. Discards all old turns completely, keeping only the summary
|
|
||||||
*
|
|
||||||
* This example also demonstrates using a different model (Gemini Flash) for summarization,
|
|
||||||
* which can be cheaper/faster than the main conversation model.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi --extension examples/extensions/custom-compaction.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { complete } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_before_compact", async (event, ctx) => {
|
|
||||||
ctx.ui.notify("Custom compaction extension triggered", "info");
|
|
||||||
|
|
||||||
const { preparation, branchEntries: _, signal } = event;
|
|
||||||
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
|
||||||
|
|
||||||
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
|
||||||
const model = ctx.modelRegistry.find("google", "gemini-2.5-flash");
|
|
||||||
if (!model) {
|
|
||||||
ctx.ui.notify(`Could not find Gemini Flash model, using default compaction`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve API key for the summarization model
|
|
||||||
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
||||||
if (!apiKey) {
|
|
||||||
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combine all messages for full summary
|
|
||||||
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
|
||||||
|
|
||||||
ctx.ui.notify(
|
|
||||||
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
|
|
||||||
"info",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Convert messages to readable text format
|
|
||||||
const conversationText = serializeConversation(convertToLlm(allMessages));
|
|
||||||
|
|
||||||
// Include previous summary context if available
|
|
||||||
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
|
|
||||||
|
|
||||||
// Build messages that ask for a comprehensive summary
|
|
||||||
const summaryMessages = [
|
|
||||||
{
|
|
||||||
role: "user" as const,
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text" as const,
|
|
||||||
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
|
|
||||||
|
|
||||||
1. The main goals and objectives discussed
|
|
||||||
2. Key decisions made and their rationale
|
|
||||||
3. Important code changes, file modifications, or technical details
|
|
||||||
4. Current state of any ongoing work
|
|
||||||
5. Any blockers, issues, or open questions
|
|
||||||
6. Next steps that were planned or suggested
|
|
||||||
|
|
||||||
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
|
|
||||||
|
|
||||||
Format the summary as structured markdown with clear sections.
|
|
||||||
|
|
||||||
<conversation>
|
|
||||||
${conversationText}
|
|
||||||
</conversation>`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Pass signal to honor abort requests (e.g., user cancels compaction)
|
|
||||||
const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192, signal });
|
|
||||||
|
|
||||||
const summary = response.content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
if (!summary.trim()) {
|
|
||||||
if (!signal.aborted) ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return compaction content - SessionManager adds id/parentId
|
|
||||||
// Use firstKeptEntryId from preparation to keep recent messages
|
|
||||||
return {
|
|
||||||
compaction: {
|
|
||||||
summary,
|
|
||||||
firstKeptEntryId,
|
|
||||||
tokensBefore,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
ctx.ui.notify(`Compaction failed: ${message}`, "error");
|
|
||||||
// Fall back to default compaction on error
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom Footer Extension - demonstrates ctx.ui.setFooter()
|
|
||||||
*
|
|
||||||
* footerData exposes data not otherwise accessible:
|
|
||||||
* - getGitBranch(): current git branch
|
|
||||||
* - getExtensionStatuses(): texts from ctx.ui.setStatus()
|
|
||||||
*
|
|
||||||
* Token stats come from ctx.sessionManager/ctx.model (already accessible).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
let enabled = false;
|
|
||||||
|
|
||||||
pi.registerCommand("footer", {
|
|
||||||
description: "Toggle custom footer",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
enabled = !enabled;
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
||||||
const unsub = footerData.onBranchChange(() => tui.requestRender());
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispose: unsub,
|
|
||||||
invalidate() {},
|
|
||||||
render(width: number): string[] {
|
|
||||||
// Compute tokens from ctx (already accessible to extensions)
|
|
||||||
let input = 0,
|
|
||||||
output = 0,
|
|
||||||
cost = 0;
|
|
||||||
for (const e of ctx.sessionManager.getBranch()) {
|
|
||||||
if (e.type === "message" && e.message.role === "assistant") {
|
|
||||||
const m = e.message as AssistantMessage;
|
|
||||||
input += m.usage.input;
|
|
||||||
output += m.usage.output;
|
|
||||||
cost += m.usage.cost.total;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get git branch (not otherwise accessible)
|
|
||||||
const branch = footerData.getGitBranch();
|
|
||||||
const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
|
|
||||||
|
|
||||||
const left = theme.fg("dim", `↑${fmt(input)} ↓${fmt(output)} $${cost.toFixed(3)}`);
|
|
||||||
const branchStr = branch ? ` (${branch})` : "";
|
|
||||||
const right = theme.fg("dim", `${ctx.model?.id || "no-model"}${branchStr}`);
|
|
||||||
|
|
||||||
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
|
|
||||||
return [truncateToWidth(left + pad + right, width)];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
ctx.ui.notify("Custom footer enabled", "info");
|
|
||||||
} else {
|
|
||||||
ctx.ui.setFooter(undefined);
|
|
||||||
ctx.ui.notify("Default footer restored", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom Header Extension
|
|
||||||
*
|
|
||||||
* Demonstrates ctx.ui.setHeader() for replacing the built-in header
|
|
||||||
* (logo + keybinding hints) with a custom component showing the pi mascot.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { VERSION } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// --- PI MASCOT ---
|
|
||||||
// Based on pi_mascot.ts - the pi agent character
|
|
||||||
function getPiMascot(theme: Theme): string[] {
|
|
||||||
// --- COLORS ---
|
|
||||||
// 3b1b Blue: R=80, G=180, B=230
|
|
||||||
const piBlue = (text: string) => theme.fg("accent", text);
|
|
||||||
const white = (text: string) => text; // Use plain white (or theme.fg("text", text))
|
|
||||||
const black = (text: string) => theme.fg("dim", text); // Use dim for contrast
|
|
||||||
|
|
||||||
// --- GLYPHS ---
|
|
||||||
const BLOCK = "█";
|
|
||||||
const PUPIL = "▌"; // Vertical half-block for the pupil
|
|
||||||
|
|
||||||
// --- CONSTRUCTION ---
|
|
||||||
|
|
||||||
// 1. The Eye Unit: [White Full Block][Black Vertical Sliver]
|
|
||||||
// This creates the "looking sideways" effect
|
|
||||||
const eye = `${white(BLOCK)}${black(PUPIL)}`;
|
|
||||||
|
|
||||||
// 2. Line 1: The Eyes
|
|
||||||
// 5 spaces indent aligns them with the start of the legs
|
|
||||||
const lineEyes = ` ${eye} ${eye}`;
|
|
||||||
|
|
||||||
// 3. Line 2: The Wide Top Bar (The "Overhang")
|
|
||||||
// 14 blocks wide for that serif-style roof
|
|
||||||
const lineBar = ` ${piBlue(BLOCK.repeat(14))}`;
|
|
||||||
|
|
||||||
// 4. Lines 3-6: The Legs
|
|
||||||
// Indented 5 spaces relative to the very left edge
|
|
||||||
// Leg width: 2 blocks | Gap: 4 blocks
|
|
||||||
const lineLeg = ` ${piBlue(BLOCK.repeat(2))} ${piBlue(BLOCK.repeat(2))}`;
|
|
||||||
|
|
||||||
// --- ASSEMBLY ---
|
|
||||||
return ["", lineEyes, lineBar, lineLeg, lineLeg, lineLeg, lineLeg, ""];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Set custom header immediately on load (if UI is available)
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
if (ctx.hasUI) {
|
|
||||||
ctx.ui.setHeader((_tui, theme) => {
|
|
||||||
return {
|
|
||||||
render(_width: number): string[] {
|
|
||||||
const mascotLines = getPiMascot(theme);
|
|
||||||
// Add a subtitle with hint
|
|
||||||
const subtitle = `${theme.fg("muted", " shitty coding agent")}${theme.fg("dim", ` v${VERSION}`)}`;
|
|
||||||
return [...mascotLines, subtitle];
|
|
||||||
},
|
|
||||||
invalidate() {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Command to restore built-in header
|
|
||||||
pi.registerCommand("builtin-header", {
|
|
||||||
description: "Restore built-in header with keybinding hints",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
ctx.ui.setHeader(undefined);
|
|
||||||
ctx.ui.notify("Built-in header restored", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
node_modules/
|
|
||||||
|
|
@ -1,604 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom Provider Example
|
|
||||||
*
|
|
||||||
* Demonstrates registering a custom provider with:
|
|
||||||
* - Custom API identifier ("custom-anthropic-api")
|
|
||||||
* - Custom streamSimple implementation
|
|
||||||
* - OAuth support for /login
|
|
||||||
* - API key support via environment variable
|
|
||||||
* - Two model definitions
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* # First install dependencies
|
|
||||||
* cd packages/coding-agent/examples/extensions/custom-provider && npm install
|
|
||||||
*
|
|
||||||
* # With OAuth (run /login custom-anthropic first)
|
|
||||||
* pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
|
||||||
*
|
|
||||||
* # With API key
|
|
||||||
* CUSTOM_ANTHROPIC_API_KEY=sk-ant-... pi -e ./packages/coding-agent/examples/extensions/custom-provider
|
|
||||||
*
|
|
||||||
* Then use /model to select custom-anthropic/claude-sonnet-4-5
|
|
||||||
*/
|
|
||||||
|
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
|
||||||
import type { ContentBlockParam, MessageCreateParamsStreaming } from "@anthropic-ai/sdk/resources/messages.js";
|
|
||||||
import {
|
|
||||||
type Api,
|
|
||||||
type AssistantMessage,
|
|
||||||
type AssistantMessageEventStream,
|
|
||||||
type Context,
|
|
||||||
calculateCost,
|
|
||||||
createAssistantMessageEventStream,
|
|
||||||
type ImageContent,
|
|
||||||
type Message,
|
|
||||||
type Model,
|
|
||||||
type OAuthCredentials,
|
|
||||||
type OAuthLoginCallbacks,
|
|
||||||
type SimpleStreamOptions,
|
|
||||||
type StopReason,
|
|
||||||
type TextContent,
|
|
||||||
type ThinkingContent,
|
|
||||||
type Tool,
|
|
||||||
type ToolCall,
|
|
||||||
type ToolResultMessage,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OAuth Implementation (copied from packages/ai/src/utils/oauth/anthropic.ts)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const decode = (s: string) => atob(s);
|
|
||||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
||||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
|
||||||
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
|
|
||||||
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
|
|
||||||
const SCOPES = "org:create_api_key user:profile user:inference";
|
|
||||||
|
|
||||||
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
|
||||||
const array = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const verifier = btoa(String.fromCharCode(...array))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(verifier);
|
|
||||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
|
|
||||||
return { verifier, challenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginAnthropic(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
||||||
const { verifier, challenge } = await generatePKCE();
|
|
||||||
|
|
||||||
const authParams = new URLSearchParams({
|
|
||||||
code: "true",
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
response_type: "code",
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
scope: SCOPES,
|
|
||||||
code_challenge: challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
state: verifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.onAuth({ url: `${AUTHORIZE_URL}?${authParams.toString()}` });
|
|
||||||
|
|
||||||
const authCode = await callbacks.onPrompt({ message: "Paste the authorization code:" });
|
|
||||||
const [code, state] = authCode.split("#");
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(TOKEN_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
code,
|
|
||||||
state,
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
code_verifier: verifier,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
|
||||||
throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await tokenResponse.json()) as {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh: data.refresh_token,
|
|
||||||
access: data.access_token,
|
|
||||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshAnthropicToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
||||||
const response = await fetch(TOKEN_URL, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
client_id: CLIENT_ID,
|
|
||||||
refresh_token: credentials.refresh,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Token refresh failed: ${await response.text()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh: data.refresh_token,
|
|
||||||
access: data.access_token,
|
|
||||||
expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Streaming Implementation (simplified from packages/ai/src/providers/anthropic.ts)
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// Claude Code tool names for OAuth stealth mode
|
|
||||||
const claudeCodeTools = [
|
|
||||||
"Read",
|
|
||||||
"Write",
|
|
||||||
"Edit",
|
|
||||||
"Bash",
|
|
||||||
"Grep",
|
|
||||||
"Glob",
|
|
||||||
"AskUserQuestion",
|
|
||||||
"TodoWrite",
|
|
||||||
"WebFetch",
|
|
||||||
"WebSearch",
|
|
||||||
];
|
|
||||||
const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t]));
|
|
||||||
const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name;
|
|
||||||
const fromClaudeCodeName = (name: string, tools?: Tool[]) => {
|
|
||||||
const lowerName = name.toLowerCase();
|
|
||||||
const matched = tools?.find((t) => t.name.toLowerCase() === lowerName);
|
|
||||||
return matched?.name ?? name;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isOAuthToken(apiKey: string): boolean {
|
|
||||||
return apiKey.includes("sk-ant-oat");
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeSurrogates(text: string): string {
|
|
||||||
return text.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertContentBlocks(
|
|
||||||
content: (TextContent | ImageContent)[],
|
|
||||||
): string | Array<{ type: "text"; text: string } | { type: "image"; source: any }> {
|
|
||||||
const hasImages = content.some((c) => c.type === "image");
|
|
||||||
if (!hasImages) {
|
|
||||||
return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const blocks = content.map((block) => {
|
|
||||||
if (block.type === "text") {
|
|
||||||
return { type: "text" as const, text: sanitizeSurrogates(block.text) };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "image" as const,
|
|
||||||
source: {
|
|
||||||
type: "base64" as const,
|
|
||||||
media_type: block.mimeType,
|
|
||||||
data: block.data,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!blocks.some((b) => b.type === "text")) {
|
|
||||||
blocks.unshift({ type: "text" as const, text: "(see attached image)" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertMessages(messages: Message[], isOAuth: boolean, _tools?: Tool[]): any[] {
|
|
||||||
const params: any[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < messages.length; i++) {
|
|
||||||
const msg = messages[i];
|
|
||||||
|
|
||||||
if (msg.role === "user") {
|
|
||||||
if (typeof msg.content === "string") {
|
|
||||||
if (msg.content.trim()) {
|
|
||||||
params.push({ role: "user", content: sanitizeSurrogates(msg.content) });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const blocks: ContentBlockParam[] = msg.content.map((item) =>
|
|
||||||
item.type === "text"
|
|
||||||
? { type: "text" as const, text: sanitizeSurrogates(item.text) }
|
|
||||||
: {
|
|
||||||
type: "image" as const,
|
|
||||||
source: { type: "base64" as const, media_type: item.mimeType as any, data: item.data },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (blocks.length > 0) {
|
|
||||||
params.push({ role: "user", content: blocks });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (msg.role === "assistant") {
|
|
||||||
const blocks: ContentBlockParam[] = [];
|
|
||||||
for (const block of msg.content) {
|
|
||||||
if (block.type === "text" && block.text.trim()) {
|
|
||||||
blocks.push({ type: "text", text: sanitizeSurrogates(block.text) });
|
|
||||||
} else if (block.type === "thinking" && block.thinking.trim()) {
|
|
||||||
if ((block as ThinkingContent).thinkingSignature) {
|
|
||||||
blocks.push({
|
|
||||||
type: "thinking" as any,
|
|
||||||
thinking: sanitizeSurrogates(block.thinking),
|
|
||||||
signature: (block as ThinkingContent).thinkingSignature!,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
blocks.push({ type: "text", text: sanitizeSurrogates(block.thinking) });
|
|
||||||
}
|
|
||||||
} else if (block.type === "toolCall") {
|
|
||||||
blocks.push({
|
|
||||||
type: "tool_use",
|
|
||||||
id: block.id,
|
|
||||||
name: isOAuth ? toClaudeCodeName(block.name) : block.name,
|
|
||||||
input: block.arguments,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (blocks.length > 0) {
|
|
||||||
params.push({ role: "assistant", content: blocks });
|
|
||||||
}
|
|
||||||
} else if (msg.role === "toolResult") {
|
|
||||||
const toolResults: any[] = [];
|
|
||||||
toolResults.push({
|
|
||||||
type: "tool_result",
|
|
||||||
tool_use_id: msg.toolCallId,
|
|
||||||
content: convertContentBlocks(msg.content),
|
|
||||||
is_error: msg.isError,
|
|
||||||
});
|
|
||||||
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < messages.length && messages[j].role === "toolResult") {
|
|
||||||
const nextMsg = messages[j] as ToolResultMessage;
|
|
||||||
toolResults.push({
|
|
||||||
type: "tool_result",
|
|
||||||
tool_use_id: nextMsg.toolCallId,
|
|
||||||
content: convertContentBlocks(nextMsg.content),
|
|
||||||
is_error: nextMsg.isError,
|
|
||||||
});
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
i = j - 1;
|
|
||||||
params.push({ role: "user", content: toolResults });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add cache control to last user message
|
|
||||||
if (params.length > 0) {
|
|
||||||
const last = params[params.length - 1];
|
|
||||||
if (last.role === "user" && Array.isArray(last.content)) {
|
|
||||||
const lastBlock = last.content[last.content.length - 1];
|
|
||||||
if (lastBlock) {
|
|
||||||
lastBlock.cache_control = { type: "ephemeral" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertTools(tools: Tool[], isOAuth: boolean): any[] {
|
|
||||||
return tools.map((tool) => ({
|
|
||||||
name: isOAuth ? toClaudeCodeName(tool.name) : tool.name,
|
|
||||||
description: tool.description,
|
|
||||||
input_schema: {
|
|
||||||
type: "object",
|
|
||||||
properties: (tool.parameters as any).properties || {},
|
|
||||||
required: (tool.parameters as any).required || [],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStopReason(reason: string): StopReason {
|
|
||||||
switch (reason) {
|
|
||||||
case "end_turn":
|
|
||||||
case "pause_turn":
|
|
||||||
case "stop_sequence":
|
|
||||||
return "stop";
|
|
||||||
case "max_tokens":
|
|
||||||
return "length";
|
|
||||||
case "tool_use":
|
|
||||||
return "toolUse";
|
|
||||||
default:
|
|
||||||
return "error";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function streamCustomAnthropic(
|
|
||||||
model: Model<Api>,
|
|
||||||
context: Context,
|
|
||||||
options?: SimpleStreamOptions,
|
|
||||||
): AssistantMessageEventStream {
|
|
||||||
const stream = createAssistantMessageEventStream();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const output: AssistantMessage = {
|
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const apiKey = options?.apiKey ?? "";
|
|
||||||
const isOAuth = isOAuthToken(apiKey);
|
|
||||||
|
|
||||||
// Configure client based on auth type
|
|
||||||
const betaFeatures = ["fine-grained-tool-streaming-2025-05-14", "interleaved-thinking-2025-05-14"];
|
|
||||||
const clientOptions: any = {
|
|
||||||
baseURL: model.baseUrl,
|
|
||||||
dangerouslyAllowBrowser: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOAuth) {
|
|
||||||
clientOptions.apiKey = null;
|
|
||||||
clientOptions.authToken = apiKey;
|
|
||||||
clientOptions.defaultHeaders = {
|
|
||||||
accept: "application/json",
|
|
||||||
"anthropic-dangerous-direct-browser-access": "true",
|
|
||||||
"anthropic-beta": `claude-code-20250219,oauth-2025-04-20,${betaFeatures.join(",")}`,
|
|
||||||
"user-agent": "claude-cli/2.1.2 (external, cli)",
|
|
||||||
"x-app": "cli",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
clientOptions.apiKey = apiKey;
|
|
||||||
clientOptions.defaultHeaders = {
|
|
||||||
accept: "application/json",
|
|
||||||
"anthropic-dangerous-direct-browser-access": "true",
|
|
||||||
"anthropic-beta": betaFeatures.join(","),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Anthropic(clientOptions);
|
|
||||||
|
|
||||||
// Build request params
|
|
||||||
const params: MessageCreateParamsStreaming = {
|
|
||||||
model: model.id,
|
|
||||||
messages: convertMessages(context.messages, isOAuth, context.tools),
|
|
||||||
max_tokens: options?.maxTokens || Math.floor(model.maxTokens / 3),
|
|
||||||
stream: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// System prompt with Claude Code identity for OAuth
|
|
||||||
if (isOAuth) {
|
|
||||||
params.system = [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "You are Claude Code, Anthropic's official CLI for Claude.",
|
|
||||||
cache_control: { type: "ephemeral" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
if (context.systemPrompt) {
|
|
||||||
params.system.push({
|
|
||||||
type: "text",
|
|
||||||
text: sanitizeSurrogates(context.systemPrompt),
|
|
||||||
cache_control: { type: "ephemeral" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (context.systemPrompt) {
|
|
||||||
params.system = [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: sanitizeSurrogates(context.systemPrompt),
|
|
||||||
cache_control: { type: "ephemeral" },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.tools) {
|
|
||||||
params.tools = convertTools(context.tools, isOAuth);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle thinking/reasoning
|
|
||||||
if (options?.reasoning && model.reasoning) {
|
|
||||||
const defaultBudgets: Record<string, number> = {
|
|
||||||
minimal: 1024,
|
|
||||||
low: 4096,
|
|
||||||
medium: 10240,
|
|
||||||
high: 20480,
|
|
||||||
};
|
|
||||||
const customBudget = options.thinkingBudgets?.[options.reasoning as keyof typeof options.thinkingBudgets];
|
|
||||||
params.thinking = {
|
|
||||||
type: "enabled",
|
|
||||||
budget_tokens: customBudget ?? defaultBudgets[options.reasoning] ?? 10240,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const anthropicStream = client.messages.stream({ ...params }, { signal: options?.signal });
|
|
||||||
stream.push({ type: "start", partial: output });
|
|
||||||
|
|
||||||
type Block = (ThinkingContent | TextContent | (ToolCall & { partialJson: string })) & { index: number };
|
|
||||||
const blocks = output.content as Block[];
|
|
||||||
|
|
||||||
for await (const event of anthropicStream) {
|
|
||||||
if (event.type === "message_start") {
|
|
||||||
output.usage.input = event.message.usage.input_tokens || 0;
|
|
||||||
output.usage.output = event.message.usage.output_tokens || 0;
|
|
||||||
output.usage.cacheRead = (event.message.usage as any).cache_read_input_tokens || 0;
|
|
||||||
output.usage.cacheWrite = (event.message.usage as any).cache_creation_input_tokens || 0;
|
|
||||||
output.usage.totalTokens =
|
|
||||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
||||||
calculateCost(model, output.usage);
|
|
||||||
} else if (event.type === "content_block_start") {
|
|
||||||
if (event.content_block.type === "text") {
|
|
||||||
output.content.push({ type: "text", text: "", index: event.index } as any);
|
|
||||||
stream.push({ type: "text_start", contentIndex: output.content.length - 1, partial: output });
|
|
||||||
} else if (event.content_block.type === "thinking") {
|
|
||||||
output.content.push({
|
|
||||||
type: "thinking",
|
|
||||||
thinking: "",
|
|
||||||
thinkingSignature: "",
|
|
||||||
index: event.index,
|
|
||||||
} as any);
|
|
||||||
stream.push({ type: "thinking_start", contentIndex: output.content.length - 1, partial: output });
|
|
||||||
} else if (event.content_block.type === "tool_use") {
|
|
||||||
output.content.push({
|
|
||||||
type: "toolCall",
|
|
||||||
id: event.content_block.id,
|
|
||||||
name: isOAuth
|
|
||||||
? fromClaudeCodeName(event.content_block.name, context.tools)
|
|
||||||
: event.content_block.name,
|
|
||||||
arguments: {},
|
|
||||||
partialJson: "",
|
|
||||||
index: event.index,
|
|
||||||
} as any);
|
|
||||||
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
|
||||||
}
|
|
||||||
} else if (event.type === "content_block_delta") {
|
|
||||||
const index = blocks.findIndex((b) => b.index === event.index);
|
|
||||||
const block = blocks[index];
|
|
||||||
if (!block) continue;
|
|
||||||
|
|
||||||
if (event.delta.type === "text_delta" && block.type === "text") {
|
|
||||||
block.text += event.delta.text;
|
|
||||||
stream.push({ type: "text_delta", contentIndex: index, delta: event.delta.text, partial: output });
|
|
||||||
} else if (event.delta.type === "thinking_delta" && block.type === "thinking") {
|
|
||||||
block.thinking += event.delta.thinking;
|
|
||||||
stream.push({
|
|
||||||
type: "thinking_delta",
|
|
||||||
contentIndex: index,
|
|
||||||
delta: event.delta.thinking,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else if (event.delta.type === "input_json_delta" && block.type === "toolCall") {
|
|
||||||
(block as any).partialJson += event.delta.partial_json;
|
|
||||||
try {
|
|
||||||
block.arguments = JSON.parse((block as any).partialJson);
|
|
||||||
} catch {}
|
|
||||||
stream.push({
|
|
||||||
type: "toolcall_delta",
|
|
||||||
contentIndex: index,
|
|
||||||
delta: event.delta.partial_json,
|
|
||||||
partial: output,
|
|
||||||
});
|
|
||||||
} else if (event.delta.type === "signature_delta" && block.type === "thinking") {
|
|
||||||
block.thinkingSignature = (block.thinkingSignature || "") + (event.delta as any).signature;
|
|
||||||
}
|
|
||||||
} else if (event.type === "content_block_stop") {
|
|
||||||
const index = blocks.findIndex((b) => b.index === event.index);
|
|
||||||
const block = blocks[index];
|
|
||||||
if (!block) continue;
|
|
||||||
|
|
||||||
delete (block as any).index;
|
|
||||||
if (block.type === "text") {
|
|
||||||
stream.push({ type: "text_end", contentIndex: index, content: block.text, partial: output });
|
|
||||||
} else if (block.type === "thinking") {
|
|
||||||
stream.push({ type: "thinking_end", contentIndex: index, content: block.thinking, partial: output });
|
|
||||||
} else if (block.type === "toolCall") {
|
|
||||||
try {
|
|
||||||
block.arguments = JSON.parse((block as any).partialJson);
|
|
||||||
} catch {}
|
|
||||||
delete (block as any).partialJson;
|
|
||||||
stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: output });
|
|
||||||
}
|
|
||||||
} else if (event.type === "message_delta") {
|
|
||||||
if ((event.delta as any).stop_reason) {
|
|
||||||
output.stopReason = mapStopReason((event.delta as any).stop_reason);
|
|
||||||
}
|
|
||||||
output.usage.input = (event.usage as any).input_tokens || 0;
|
|
||||||
output.usage.output = (event.usage as any).output_tokens || 0;
|
|
||||||
output.usage.cacheRead = (event.usage as any).cache_read_input_tokens || 0;
|
|
||||||
output.usage.cacheWrite = (event.usage as any).cache_creation_input_tokens || 0;
|
|
||||||
output.usage.totalTokens =
|
|
||||||
output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite;
|
|
||||||
calculateCost(model, output.usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options?.signal?.aborted) {
|
|
||||||
throw new Error("Request was aborted");
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse", message: output });
|
|
||||||
stream.end();
|
|
||||||
} catch (error) {
|
|
||||||
for (const block of output.content) delete (block as any).index;
|
|
||||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
||||||
output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
|
|
||||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
|
||||||
stream.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Extension Entry Point
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerProvider("custom-anthropic", {
|
|
||||||
baseUrl: "https://api.anthropic.com",
|
|
||||||
apiKey: "CUSTOM_ANTHROPIC_API_KEY",
|
|
||||||
api: "custom-anthropic-api",
|
|
||||||
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "claude-opus-4-5",
|
|
||||||
name: "Claude Opus 4.5 (Custom)",
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 64000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-sonnet-4-5",
|
|
||||||
name: "Claude Sonnet 4.5 (Custom)",
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 64000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
oauth: {
|
|
||||||
name: "Custom Anthropic (Claude Pro/Max)",
|
|
||||||
login: loginAnthropic,
|
|
||||||
refreshToken: refreshAnthropicToken,
|
|
||||||
getApiKey: (cred) => cred.access,
|
|
||||||
},
|
|
||||||
|
|
||||||
streamSimple: streamCustomAnthropic,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-custom-provider",
|
|
||||||
"version": "1.7.2",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "pi-extension-custom-provider",
|
|
||||||
"version": "1.7.2",
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/sdk": "^0.52.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@anthropic-ai/sdk": {
|
|
||||||
"version": "0.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.52.0.tgz",
|
|
||||||
"integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"anthropic-ai-sdk": "bin/cli"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-custom-provider-anthropic",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.7.2",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"clean": "echo 'nothing to clean'",
|
|
||||||
"build": "echo 'nothing to build'",
|
|
||||||
"check": "echo 'nothing to check'"
|
|
||||||
},
|
|
||||||
"pi": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/sdk": "^0.52.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
node_modules/
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
/**
|
|
||||||
* GitLab Duo Provider Extension
|
|
||||||
*
|
|
||||||
* Provides access to GitLab Duo AI models (Claude and GPT) through GitLab's AI Gateway.
|
|
||||||
* Delegates to pi-ai's built-in Anthropic and OpenAI streaming implementations.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./packages/coding-agent/examples/extensions/custom-provider-gitlab-duo
|
|
||||||
* # Then /login gitlab-duo, or set GITLAB_TOKEN=glpat-...
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
type Api,
|
|
||||||
type AssistantMessageEventStream,
|
|
||||||
type Context,
|
|
||||||
createAssistantMessageEventStream,
|
|
||||||
type Model,
|
|
||||||
type OAuthCredentials,
|
|
||||||
type OAuthLoginCallbacks,
|
|
||||||
type SimpleStreamOptions,
|
|
||||||
streamSimpleAnthropic,
|
|
||||||
streamSimpleOpenAIResponses,
|
|
||||||
} from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Constants
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const GITLAB_COM_URL = "https://gitlab.com";
|
|
||||||
const AI_GATEWAY_URL = "https://cloud.gitlab.com";
|
|
||||||
const ANTHROPIC_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/anthropic/`;
|
|
||||||
const OPENAI_PROXY_URL = `${AI_GATEWAY_URL}/ai/v1/proxy/openai/v1`;
|
|
||||||
|
|
||||||
const BUNDLED_CLIENT_ID = "da4edff2e6ebd2bc3208611e2768bc1c1dd7be791dc5ff26ca34ca9ee44f7d4b";
|
|
||||||
const OAUTH_SCOPES = ["api"];
|
|
||||||
const REDIRECT_URI = "http://127.0.0.1:8080/callback";
|
|
||||||
const DIRECT_ACCESS_TTL = 25 * 60 * 1000;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Models - exported for use by tests
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
type Backend = "anthropic" | "openai";
|
|
||||||
|
|
||||||
interface GitLabModel {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
backend: Backend;
|
|
||||||
baseUrl: string;
|
|
||||||
reasoning: boolean;
|
|
||||||
input: ("text" | "image")[];
|
|
||||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
||||||
contextWindow: number;
|
|
||||||
maxTokens: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MODELS: GitLabModel[] = [
|
|
||||||
// Anthropic
|
|
||||||
{
|
|
||||||
id: "claude-opus-4-5-20251101",
|
|
||||||
name: "Claude Opus 4.5",
|
|
||||||
backend: "anthropic",
|
|
||||||
baseUrl: ANTHROPIC_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 32000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-sonnet-4-5-20250929",
|
|
||||||
name: "Claude Sonnet 4.5",
|
|
||||||
backend: "anthropic",
|
|
||||||
baseUrl: ANTHROPIC_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 16384,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "claude-haiku-4-5-20251001",
|
|
||||||
name: "Claude Haiku 4.5",
|
|
||||||
backend: "anthropic",
|
|
||||||
baseUrl: ANTHROPIC_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 8192,
|
|
||||||
},
|
|
||||||
// OpenAI (all use Responses API)
|
|
||||||
{
|
|
||||||
id: "gpt-5.1-2025-11-13",
|
|
||||||
name: "GPT-5.1",
|
|
||||||
backend: "openai",
|
|
||||||
baseUrl: OPENAI_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 16384,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5-mini-2025-08-07",
|
|
||||||
name: "GPT-5 Mini",
|
|
||||||
backend: "openai",
|
|
||||||
baseUrl: OPENAI_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 0.15, output: 0.6, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 16384,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gpt-5-codex",
|
|
||||||
name: "GPT-5 Codex",
|
|
||||||
backend: "openai",
|
|
||||||
baseUrl: OPENAI_PROXY_URL,
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 128000,
|
|
||||||
maxTokens: 16384,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const MODEL_MAP = new Map(MODELS.map((m) => [m.id, m]));
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Direct Access Token Cache
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface DirectAccessToken {
|
|
||||||
token: string;
|
|
||||||
headers: Record<string, string>;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedDirectAccess: DirectAccessToken | null = null;
|
|
||||||
|
|
||||||
async function getDirectAccessToken(gitlabAccessToken: string): Promise<DirectAccessToken> {
|
|
||||||
const now = Date.now();
|
|
||||||
if (cachedDirectAccess && cachedDirectAccess.expiresAt > now) {
|
|
||||||
return cachedDirectAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(`${GITLAB_COM_URL}/api/v4/ai/third_party_agents/direct_access`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `Bearer ${gitlabAccessToken}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ feature_flags: { DuoAgentPlatformNext: true } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
if (response.status === 403) {
|
|
||||||
throw new Error(
|
|
||||||
`GitLab Duo access denied. Ensure GitLab Duo is enabled for your account. Error: ${errorText}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw new Error(`Failed to get direct access token: ${response.status} ${errorText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as { token: string; headers: Record<string, string> };
|
|
||||||
cachedDirectAccess = { token: data.token, headers: data.headers, expiresAt: now + DIRECT_ACCESS_TTL };
|
|
||||||
return cachedDirectAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateDirectAccessToken() {
|
|
||||||
cachedDirectAccess = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OAuth
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
|
||||||
const array = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const verifier = btoa(String.fromCharCode(...array))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
||||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
return { verifier, challenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginGitLab(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
||||||
const { verifier, challenge } = await generatePKCE();
|
|
||||||
const authParams = new URLSearchParams({
|
|
||||||
client_id: BUNDLED_CLIENT_ID,
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
response_type: "code",
|
|
||||||
scope: OAUTH_SCOPES.join(" "),
|
|
||||||
code_challenge: challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
state: crypto.randomUUID(),
|
|
||||||
});
|
|
||||||
|
|
||||||
callbacks.onAuth({ url: `${GITLAB_COM_URL}/oauth/authorize?${authParams.toString()}` });
|
|
||||||
const callbackUrl = await callbacks.onPrompt({ message: "Paste the callback URL:" });
|
|
||||||
const code = new URL(callbackUrl).searchParams.get("code");
|
|
||||||
if (!code) throw new Error("No authorization code found in callback URL");
|
|
||||||
|
|
||||||
const tokenResponse = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: BUNDLED_CLIENT_ID,
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code,
|
|
||||||
code_verifier: verifier,
|
|
||||||
redirect_uri: REDIRECT_URI,
|
|
||||||
}).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!tokenResponse.ok) throw new Error(`Token exchange failed: ${await tokenResponse.text()}`);
|
|
||||||
const data = (await tokenResponse.json()) as {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
created_at: number;
|
|
||||||
};
|
|
||||||
invalidateDirectAccessToken();
|
|
||||||
return {
|
|
||||||
refresh: data.refresh_token,
|
|
||||||
access: data.access_token,
|
|
||||||
expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshGitLabToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
||||||
const response = await fetch(`${GITLAB_COM_URL}/oauth/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: new URLSearchParams({
|
|
||||||
client_id: BUNDLED_CLIENT_ID,
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: credentials.refresh,
|
|
||||||
}).toString(),
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error(`Token refresh failed: ${await response.text()}`);
|
|
||||||
const data = (await response.json()) as {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
expires_in: number;
|
|
||||||
created_at: number;
|
|
||||||
};
|
|
||||||
invalidateDirectAccessToken();
|
|
||||||
return {
|
|
||||||
refresh: data.refresh_token,
|
|
||||||
access: data.access_token,
|
|
||||||
expires: (data.created_at + data.expires_in) * 1000 - 5 * 60 * 1000,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Stream Function
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export function streamGitLabDuo(
|
|
||||||
model: Model<Api>,
|
|
||||||
context: Context,
|
|
||||||
options?: SimpleStreamOptions,
|
|
||||||
): AssistantMessageEventStream {
|
|
||||||
const stream = createAssistantMessageEventStream();
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const gitlabAccessToken = options?.apiKey;
|
|
||||||
if (!gitlabAccessToken) throw new Error("No GitLab access token. Run /login gitlab-duo or set GITLAB_TOKEN");
|
|
||||||
|
|
||||||
const cfg = MODEL_MAP.get(model.id);
|
|
||||||
if (!cfg) throw new Error(`Unknown model: ${model.id}`);
|
|
||||||
|
|
||||||
const directAccess = await getDirectAccessToken(gitlabAccessToken);
|
|
||||||
const modelWithBaseUrl = { ...model, baseUrl: cfg.baseUrl };
|
|
||||||
const headers = { ...directAccess.headers, Authorization: `Bearer ${directAccess.token}` };
|
|
||||||
const streamOptions = { ...options, apiKey: "gitlab-duo", headers };
|
|
||||||
|
|
||||||
const innerStream =
|
|
||||||
cfg.backend === "anthropic"
|
|
||||||
? streamSimpleAnthropic(modelWithBaseUrl as Model<"anthropic-messages">, context, streamOptions)
|
|
||||||
: streamSimpleOpenAIResponses(modelWithBaseUrl as Model<"openai-responses">, context, streamOptions);
|
|
||||||
|
|
||||||
for await (const event of innerStream) stream.push(event);
|
|
||||||
stream.end();
|
|
||||||
} catch (error) {
|
|
||||||
stream.push({
|
|
||||||
type: "error",
|
|
||||||
reason: "error",
|
|
||||||
error: {
|
|
||||||
role: "assistant",
|
|
||||||
content: [],
|
|
||||||
api: model.api,
|
|
||||||
provider: model.provider,
|
|
||||||
model: model.id,
|
|
||||||
usage: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
},
|
|
||||||
stopReason: "error",
|
|
||||||
errorMessage: error instanceof Error ? error.message : String(error),
|
|
||||||
timestamp: Date.now(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
stream.end();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return stream;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Extension Entry Point
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerProvider("gitlab-duo", {
|
|
||||||
baseUrl: AI_GATEWAY_URL,
|
|
||||||
apiKey: "GITLAB_TOKEN",
|
|
||||||
api: "gitlab-duo-api",
|
|
||||||
models: MODELS.map(({ id, name, reasoning, input, cost, contextWindow, maxTokens }) => ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
reasoning,
|
|
||||||
input,
|
|
||||||
cost,
|
|
||||||
contextWindow,
|
|
||||||
maxTokens,
|
|
||||||
})),
|
|
||||||
oauth: {
|
|
||||||
name: "GitLab Duo",
|
|
||||||
login: loginGitLab,
|
|
||||||
refreshToken: refreshGitLabToken,
|
|
||||||
getApiKey: (cred) => cred.access,
|
|
||||||
},
|
|
||||||
streamSimple: streamGitLabDuo,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-custom-provider-gitlab-duo",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.7.2",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"clean": "echo 'nothing to clean'",
|
|
||||||
"build": "echo 'nothing to build'",
|
|
||||||
"check": "echo 'nothing to check'"
|
|
||||||
},
|
|
||||||
"pi": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
/**
|
|
||||||
* Test script for GitLab Duo extension
|
|
||||||
* Run: npx tsx test.ts [model-id] [--thinking]
|
|
||||||
*
|
|
||||||
* Examples:
|
|
||||||
* npx tsx test.ts # Test default (claude-sonnet-4-5-20250929)
|
|
||||||
* npx tsx test.ts gpt-5-codex # Test GPT-5 Codex
|
|
||||||
* npx tsx test.ts claude-sonnet-4-5-20250929 --thinking
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { type Api, type Context, type Model, registerApiProvider, streamSimple } from "@mariozechner/pi-ai";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { homedir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import { MODELS, streamGitLabDuo } from "./index.js";
|
|
||||||
|
|
||||||
const MODEL_MAP = new Map(MODELS.map((m) => [m.id, m]));
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const modelId = process.argv[2] || "claude-sonnet-4-5-20250929";
|
|
||||||
const useThinking = process.argv.includes("--thinking");
|
|
||||||
|
|
||||||
const cfg = MODEL_MAP.get(modelId);
|
|
||||||
if (!cfg) {
|
|
||||||
console.error(`Unknown model: ${modelId}`);
|
|
||||||
console.error("Available:", MODELS.map((m) => m.id).join(", "));
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read auth
|
|
||||||
const authPath = join(homedir(), ".pi", "agent", "auth.json");
|
|
||||||
const authData = JSON.parse(readFileSync(authPath, "utf-8"));
|
|
||||||
const gitlabCred = authData["gitlab-duo"];
|
|
||||||
if (!gitlabCred?.access) {
|
|
||||||
console.error("No gitlab-duo credentials. Run /login gitlab-duo first.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register provider
|
|
||||||
registerApiProvider({
|
|
||||||
api: "gitlab-duo-api" as Api,
|
|
||||||
stream: streamGitLabDuo,
|
|
||||||
streamSimple: streamGitLabDuo,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create model
|
|
||||||
const model: Model<Api> = {
|
|
||||||
id: cfg.id,
|
|
||||||
name: cfg.name,
|
|
||||||
api: "gitlab-duo-api" as Api,
|
|
||||||
provider: "gitlab-duo",
|
|
||||||
baseUrl: cfg.baseUrl,
|
|
||||||
reasoning: cfg.reasoning,
|
|
||||||
input: cfg.input,
|
|
||||||
cost: cfg.cost,
|
|
||||||
contextWindow: cfg.contextWindow,
|
|
||||||
maxTokens: cfg.maxTokens,
|
|
||||||
};
|
|
||||||
|
|
||||||
const context: Context = {
|
|
||||||
messages: [{ role: "user", content: "Say hello in exactly 3 words.", timestamp: Date.now() }],
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log(`Model: ${model.id}, Backend: ${cfg.backend}, Thinking: ${useThinking}`);
|
|
||||||
|
|
||||||
const stream = streamSimple(model, context, {
|
|
||||||
apiKey: gitlabCred.access,
|
|
||||||
maxTokens: 100,
|
|
||||||
reasoning: useThinking ? "low" : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const event of stream) {
|
|
||||||
if (event.type === "thinking_start") console.log("[Thinking]");
|
|
||||||
else if (event.type === "thinking_delta") process.stdout.write(event.delta);
|
|
||||||
else if (event.type === "thinking_end") console.log("\n[/Thinking]\n");
|
|
||||||
else if (event.type === "text_delta") process.stdout.write(event.delta);
|
|
||||||
else if (event.type === "error") console.error("\nError:", event.error.errorMessage);
|
|
||||||
else if (event.type === "done") console.log("\n\nDone!", event.reason, event.message.usage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(console.error);
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
node_modules/
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
/**
|
|
||||||
* Qwen CLI Provider Extension
|
|
||||||
*
|
|
||||||
* Provides access to Qwen models via OAuth authentication with chat.qwen.ai.
|
|
||||||
* Uses device code flow with PKCE for secure browser-based authentication.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./packages/coding-agent/examples/extensions/custom-provider-qwen-cli
|
|
||||||
* # Then /login qwen-cli, or set QWEN_CLI_API_KEY=...
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Constants
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code";
|
|
||||||
const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token";
|
|
||||||
const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
|
|
||||||
const QWEN_SCOPE = "openid profile email model.completion";
|
|
||||||
const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
||||||
const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
|
||||||
const QWEN_POLL_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PKCE Helpers
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
|
|
||||||
const array = new Uint8Array(32);
|
|
||||||
crypto.getRandomValues(array);
|
|
||||||
const verifier = btoa(String.fromCharCode(...array))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const data = encoder.encode(verifier);
|
|
||||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
||||||
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "");
|
|
||||||
|
|
||||||
return { verifier, challenge };
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// OAuth Implementation
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface DeviceCodeResponse {
|
|
||||||
device_code: string;
|
|
||||||
user_code: string;
|
|
||||||
verification_uri: string;
|
|
||||||
verification_uri_complete?: string;
|
|
||||||
expires_in: number;
|
|
||||||
interval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
refresh_token?: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
resource_url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("Login cancelled"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timeout = setTimeout(resolve, ms);
|
|
||||||
signal?.addEventListener(
|
|
||||||
"abort",
|
|
||||||
() => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
reject(new Error("Login cancelled"));
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> {
|
|
||||||
const { verifier, challenge } = await generatePKCE();
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
client_id: QWEN_CLIENT_ID,
|
|
||||||
scope: QWEN_SCOPE,
|
|
||||||
code_challenge: challenge,
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
});
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
};
|
|
||||||
const requestId = globalThis.crypto?.randomUUID?.();
|
|
||||||
if (requestId) headers["x-request-id"] = requestId;
|
|
||||||
|
|
||||||
const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Device code request failed: ${response.status} ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as DeviceCodeResponse;
|
|
||||||
|
|
||||||
if (!data.device_code || !data.user_code || !data.verification_uri) {
|
|
||||||
throw new Error("Invalid device code response: missing required fields");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { deviceCode: data, verifier };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollForToken(
|
|
||||||
deviceCode: string,
|
|
||||||
verifier: string,
|
|
||||||
intervalSeconds: number | undefined,
|
|
||||||
expiresIn: number,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<TokenResponse> {
|
|
||||||
const deadline = Date.now() + expiresIn * 1000;
|
|
||||||
const resolvedIntervalSeconds =
|
|
||||||
typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0
|
|
||||||
? intervalSeconds
|
|
||||||
: QWEN_POLL_INTERVAL_MS / 1000;
|
|
||||||
let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000));
|
|
||||||
|
|
||||||
const handleTokenError = async (error: string, description?: string): Promise<boolean> => {
|
|
||||||
switch (error) {
|
|
||||||
case "authorization_pending":
|
|
||||||
await abortableSleep(intervalMs, signal);
|
|
||||||
return true;
|
|
||||||
case "slow_down":
|
|
||||||
intervalMs = Math.min(intervalMs + 5000, 10000);
|
|
||||||
await abortableSleep(intervalMs, signal);
|
|
||||||
return true;
|
|
||||||
case "expired_token":
|
|
||||||
throw new Error("Device code expired. Please restart authentication.");
|
|
||||||
case "access_denied":
|
|
||||||
throw new Error("Authorization denied by user.");
|
|
||||||
default:
|
|
||||||
throw new Error(`Token request failed: ${error} - ${description || ""}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
throw new Error("Login cancelled");
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: QWEN_GRANT_TYPE,
|
|
||||||
client_id: QWEN_CLIENT_ID,
|
|
||||||
device_code: deviceCode,
|
|
||||||
code_verifier: verifier,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseText = await response.text();
|
|
||||||
let data: (TokenResponse & { error?: string; error_description?: string }) | null = null;
|
|
||||||
if (responseText) {
|
|
||||||
try {
|
|
||||||
data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string };
|
|
||||||
} catch {
|
|
||||||
data = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = data?.error;
|
|
||||||
const errorDescription = data?.error_description;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (error && (await handleTokenError(error, errorDescription))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data?.access_token) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && (await handleTokenError(error, errorDescription))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Token request failed: missing access token in response");
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Authentication timed out. Please try again.");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loginQwen(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
|
|
||||||
const { deviceCode, verifier } = await startDeviceFlow();
|
|
||||||
|
|
||||||
// Show verification URL and user code to user
|
|
||||||
const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri;
|
|
||||||
const instructions = deviceCode.verification_uri_complete
|
|
||||||
? undefined // Code is already embedded in the URL
|
|
||||||
: `Enter code: ${deviceCode.user_code}`;
|
|
||||||
callbacks.onAuth({ url: authUrl, instructions });
|
|
||||||
|
|
||||||
// Poll for token
|
|
||||||
const tokenResponse = await pollForToken(
|
|
||||||
deviceCode.device_code,
|
|
||||||
verifier,
|
|
||||||
deviceCode.interval,
|
|
||||||
deviceCode.expires_in,
|
|
||||||
callbacks.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate expiry with 5-minute buffer
|
|
||||||
const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000;
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh: tokenResponse.refresh_token || "",
|
|
||||||
access: tokenResponse.access_token,
|
|
||||||
expires: expiresAt,
|
|
||||||
// Store resource_url for API base URL if provided
|
|
||||||
enterpriseUrl: tokenResponse.resource_url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshQwenToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
||||||
const body = new URLSearchParams({
|
|
||||||
grant_type: "refresh_token",
|
|
||||||
refresh_token: credentials.refresh,
|
|
||||||
client_id: QWEN_CLIENT_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`Token refresh failed: ${response.status} ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await response.json()) as TokenResponse;
|
|
||||||
|
|
||||||
if (!data.access_token) {
|
|
||||||
throw new Error("Token refresh failed: no access token in response");
|
|
||||||
}
|
|
||||||
|
|
||||||
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
|
|
||||||
|
|
||||||
return {
|
|
||||||
refresh: data.refresh_token || credentials.refresh,
|
|
||||||
access: data.access_token,
|
|
||||||
expires: expiresAt,
|
|
||||||
enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getQwenBaseUrl(resourceUrl?: string): string {
|
|
||||||
if (!resourceUrl) {
|
|
||||||
return QWEN_DEFAULT_BASE_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`;
|
|
||||||
if (!url.endsWith("/v1")) {
|
|
||||||
url = `${url}/v1`;
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Extension Entry Point
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerProvider("qwen-cli", {
|
|
||||||
baseUrl: QWEN_DEFAULT_BASE_URL,
|
|
||||||
apiKey: "QWEN_CLI_API_KEY",
|
|
||||||
api: "openai-completions",
|
|
||||||
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "qwen3-coder-plus",
|
|
||||||
name: "Qwen3 Coder Plus",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 1000000,
|
|
||||||
maxTokens: 65536,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "qwen3-coder-flash",
|
|
||||||
name: "Qwen3 Coder Flash",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 1000000,
|
|
||||||
maxTokens: 65536,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "vision-model",
|
|
||||||
name: "Qwen3 VL Plus",
|
|
||||||
reasoning: true,
|
|
||||||
input: ["text", "image"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 262144,
|
|
||||||
maxTokens: 32768,
|
|
||||||
compat: { supportsDeveloperRole: false, thinkingFormat: "qwen" },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
|
|
||||||
oauth: {
|
|
||||||
name: "Qwen CLI",
|
|
||||||
login: loginQwen,
|
|
||||||
refreshToken: refreshQwenToken,
|
|
||||||
getApiKey: (cred) => cred.access,
|
|
||||||
modifyModels: (models, cred) => {
|
|
||||||
const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined);
|
|
||||||
return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-custom-provider-qwen-cli",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.6.2",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"clean": "echo 'nothing to clean'",
|
|
||||||
"build": "echo 'nothing to build'",
|
|
||||||
"check": "echo 'nothing to check'"
|
|
||||||
},
|
|
||||||
"pi": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
/**
|
|
||||||
* Dirty Repo Guard Extension
|
|
||||||
*
|
|
||||||
* Prevents session changes when there are uncommitted git changes.
|
|
||||||
* Useful to ensure work is committed before switching context.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
async function checkDirtyRepo(
|
|
||||||
pi: ExtensionAPI,
|
|
||||||
ctx: ExtensionContext,
|
|
||||||
action: string,
|
|
||||||
): Promise<{ cancel: boolean } | undefined> {
|
|
||||||
// Check for uncommitted changes
|
|
||||||
const { stdout, code } = await pi.exec("git", ["status", "--porcelain"]);
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
// Not a git repo, allow the action
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasChanges = stdout.trim().length > 0;
|
|
||||||
if (!hasChanges) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
// In non-interactive mode, block by default
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count changed files
|
|
||||||
const changedFiles = stdout.trim().split("\n").filter(Boolean).length;
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select(`You have ${changedFiles} uncommitted file(s). ${action} anyway?`, [
|
|
||||||
"Yes, proceed anyway",
|
|
||||||
"No, let me commit first",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (choice !== "Yes, proceed anyway") {
|
|
||||||
ctx.ui.notify("Commit your changes first", "warning");
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_before_switch", async (event, ctx) => {
|
|
||||||
const action = event.reason === "new" ? "new session" : "switch session";
|
|
||||||
return checkDirtyRepo(pi, ctx, action);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_before_fork", async (_event, ctx) => {
|
|
||||||
return checkDirtyRepo(pi, ctx, "fork");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
# Auto-downloaded on first run
|
|
||||||
doom1.wad
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
# DOOM Overlay Demo
|
|
||||||
|
|
||||||
Play DOOM as an overlay in pi. Demonstrates that the overlay system can handle real-time game rendering at 35 FPS.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pi --extension ./examples/extensions/doom-overlay
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run:
|
|
||||||
```
|
|
||||||
/doom-overlay
|
|
||||||
```
|
|
||||||
|
|
||||||
The shareware WAD file (~4MB) is auto-downloaded on first run.
|
|
||||||
|
|
||||||
## Controls
|
|
||||||
|
|
||||||
| Action | Keys |
|
|
||||||
|--------|------|
|
|
||||||
| Move | WASD or Arrow Keys |
|
|
||||||
| Run | Shift + WASD |
|
|
||||||
| Fire | F or Ctrl |
|
|
||||||
| Use/Open | Space |
|
|
||||||
| Weapons | 1-7 |
|
|
||||||
| Map | Tab |
|
|
||||||
| Menu | Escape |
|
|
||||||
| Pause/Quit | Q |
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
DOOM runs as WebAssembly compiled from [doomgeneric](https://github.com/ozkl/doomgeneric). Each frame is rendered using half-block characters (▀) with 24-bit color, where the top pixel is the foreground color and the bottom pixel is the background color.
|
|
||||||
|
|
||||||
The overlay uses:
|
|
||||||
- `width: "90%"` - 90% of terminal width
|
|
||||||
- `maxHeight: "80%"` - Maximum 80% of terminal height
|
|
||||||
- `anchor: "center"` - Centered in terminal
|
|
||||||
|
|
||||||
Height is calculated from width to maintain DOOM's 3.2:1 aspect ratio (accounting for half-block rendering).
|
|
||||||
|
|
||||||
## Credits
|
|
||||||
|
|
||||||
- [id Software](https://github.com/id-Software/DOOM) for the original DOOM
|
|
||||||
- [doomgeneric](https://github.com/ozkl/doomgeneric) for the portable DOOM implementation
|
|
||||||
- [pi-doom](https://github.com/badlogic/pi-doom) for the original pi integration
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
/**
|
|
||||||
* DOOM Component for overlay mode
|
|
||||||
*
|
|
||||||
* Renders DOOM frames using half-block characters (▀) with 24-bit color.
|
|
||||||
* Height is calculated from width to maintain DOOM's aspect ratio.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Component } from "@mariozechner/pi-tui";
|
|
||||||
import { isKeyRelease, type TUI } from "@mariozechner/pi-tui";
|
|
||||||
import type { DoomEngine } from "./doom-engine.js";
|
|
||||||
import { DoomKeys, mapKeyToDoom } from "./doom-keys.js";
|
|
||||||
|
|
||||||
function renderHalfBlock(
|
|
||||||
rgba: Uint8Array,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
targetCols: number,
|
|
||||||
targetRows: number,
|
|
||||||
): string[] {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const scaleX = width / targetCols;
|
|
||||||
const scaleY = height / (targetRows * 2);
|
|
||||||
|
|
||||||
for (let row = 0; row < targetRows; row++) {
|
|
||||||
let line = "";
|
|
||||||
const srcY1 = Math.floor(row * 2 * scaleY);
|
|
||||||
const srcY2 = Math.floor((row * 2 + 1) * scaleY);
|
|
||||||
|
|
||||||
for (let col = 0; col < targetCols; col++) {
|
|
||||||
const srcX = Math.floor(col * scaleX);
|
|
||||||
const idx1 = (srcY1 * width + srcX) * 4;
|
|
||||||
const idx2 = (srcY2 * width + srcX) * 4;
|
|
||||||
const r1 = rgba[idx1] ?? 0,
|
|
||||||
g1 = rgba[idx1 + 1] ?? 0,
|
|
||||||
b1 = rgba[idx1 + 2] ?? 0;
|
|
||||||
const r2 = rgba[idx2] ?? 0,
|
|
||||||
g2 = rgba[idx2 + 1] ?? 0,
|
|
||||||
b2 = rgba[idx2 + 2] ?? 0;
|
|
||||||
line += `\x1b[38;2;${r1};${g1};${b1}m\x1b[48;2;${r2};${g2};${b2}m▀`;
|
|
||||||
}
|
|
||||||
line += "\x1b[0m";
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DoomOverlayComponent implements Component {
|
|
||||||
private engine: DoomEngine;
|
|
||||||
private tui: TUI;
|
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private onExit: () => void;
|
|
||||||
|
|
||||||
// Opt-in to key release events for smooth movement
|
|
||||||
wantsKeyRelease = true;
|
|
||||||
|
|
||||||
constructor(tui: TUI, engine: DoomEngine, onExit: () => void, resume = false) {
|
|
||||||
this.tui = tui;
|
|
||||||
this.engine = engine;
|
|
||||||
this.onExit = onExit;
|
|
||||||
|
|
||||||
// Unpause if resuming
|
|
||||||
if (resume) {
|
|
||||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
|
||||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.startGameLoop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startGameLoop(): void {
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
try {
|
|
||||||
this.engine.tick();
|
|
||||||
this.tui.requestRender();
|
|
||||||
} catch {
|
|
||||||
// WASM error (e.g., exit via DOOM menu) - treat as quit
|
|
||||||
this.dispose();
|
|
||||||
this.onExit();
|
|
||||||
}
|
|
||||||
}, 1000 / 35);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
// Q to pause and exit (but not on release)
|
|
||||||
if (!isKeyRelease(data) && (data === "q" || data === "Q")) {
|
|
||||||
// Send DOOM's pause key before exiting
|
|
||||||
this.engine.pushKey(true, DoomKeys.KEY_PAUSE);
|
|
||||||
this.engine.pushKey(false, DoomKeys.KEY_PAUSE);
|
|
||||||
this.dispose();
|
|
||||||
this.onExit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const doomKeys = mapKeyToDoom(data);
|
|
||||||
if (doomKeys.length === 0) return;
|
|
||||||
|
|
||||||
const released = isKeyRelease(data);
|
|
||||||
|
|
||||||
for (const key of doomKeys) {
|
|
||||||
this.engine.pushKey(!released, key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
// DOOM renders at 640x400 (1.6:1 ratio)
|
|
||||||
// With half-block characters, each terminal row = 2 pixels
|
|
||||||
// So effective ratio is 640:200 = 3.2:1 (width:height in terminal cells)
|
|
||||||
// Add 1 row for footer
|
|
||||||
const ASPECT_RATIO = 3.2;
|
|
||||||
const MIN_HEIGHT = 10;
|
|
||||||
const height = Math.max(MIN_HEIGHT, Math.floor(width / ASPECT_RATIO));
|
|
||||||
|
|
||||||
const rgba = this.engine.getFrameRGBA();
|
|
||||||
const lines = renderHalfBlock(rgba, this.engine.width, this.engine.height, width, height);
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
const footer = " DOOM | Q=Pause | WASD=Move | Shift+WASD=Run | Space=Use | F=Fire | 1-7=Weapons";
|
|
||||||
const truncatedFooter = footer.length > width ? footer.slice(0, width) : footer;
|
|
||||||
lines.push(`\x1b[2m${truncatedFooter}\x1b[0m`);
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(): void {}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
/**
|
|
||||||
* DOOM Engine - WebAssembly wrapper for doomgeneric
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
export interface DoomModule {
|
|
||||||
_doomgeneric_Create: (argc: number, argv: number) => void;
|
|
||||||
_doomgeneric_Tick: () => void;
|
|
||||||
_DG_GetFrameBuffer: () => number;
|
|
||||||
_DG_GetScreenWidth: () => number;
|
|
||||||
_DG_GetScreenHeight: () => number;
|
|
||||||
_DG_PushKeyEvent: (pressed: number, key: number) => void;
|
|
||||||
_malloc: (size: number) => number;
|
|
||||||
_free: (ptr: number) => void;
|
|
||||||
HEAPU8: Uint8Array;
|
|
||||||
HEAPU32: Uint32Array;
|
|
||||||
FS_createDataFile: (parent: string, name: string, data: number[], canRead: boolean, canWrite: boolean) => void;
|
|
||||||
FS_createPath: (parent: string, path: string, canRead: boolean, canWrite: boolean) => string;
|
|
||||||
setValue: (ptr: number, value: number, type: string) => void;
|
|
||||||
getValue: (ptr: number, type: string) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DoomEngine {
|
|
||||||
private module: DoomModule | null = null;
|
|
||||||
private frameBufferPtr: number = 0;
|
|
||||||
private initialized = false;
|
|
||||||
private wadPath: string;
|
|
||||||
private _width = 640;
|
|
||||||
private _height = 400;
|
|
||||||
|
|
||||||
constructor(wadPath: string) {
|
|
||||||
this.wadPath = wadPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
get width(): number {
|
|
||||||
return this._width;
|
|
||||||
}
|
|
||||||
|
|
||||||
get height(): number {
|
|
||||||
return this._height;
|
|
||||||
}
|
|
||||||
|
|
||||||
async init(): Promise<void> {
|
|
||||||
// Locate WASM build
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const buildDir = join(__dirname, "doom", "build");
|
|
||||||
const doomJsPath = join(buildDir, "doom.js");
|
|
||||||
|
|
||||||
if (!existsSync(doomJsPath)) {
|
|
||||||
throw new Error(`WASM not found at ${doomJsPath}. Run ./doom/build.sh first`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read WAD file
|
|
||||||
const wadData = readFileSync(this.wadPath);
|
|
||||||
const wadArray = Array.from(new Uint8Array(wadData));
|
|
||||||
|
|
||||||
// Load WASM module - eval to bypass jiti completely
|
|
||||||
const doomJsCode = readFileSync(doomJsPath, "utf-8");
|
|
||||||
const moduleExports: { exports: unknown } = { exports: {} };
|
|
||||||
const nativeRequire = createRequire(doomJsPath);
|
|
||||||
const moduleFunc = new Function("module", "exports", "__dirname", "__filename", "require", doomJsCode);
|
|
||||||
moduleFunc(moduleExports, moduleExports.exports, buildDir, doomJsPath, nativeRequire);
|
|
||||||
const createDoomModule = moduleExports.exports as (config: unknown) => Promise<DoomModule>;
|
|
||||||
|
|
||||||
const moduleConfig = {
|
|
||||||
locateFile: (path: string) => {
|
|
||||||
if (path.endsWith(".wasm")) {
|
|
||||||
return join(buildDir, path);
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
},
|
|
||||||
print: () => {},
|
|
||||||
printErr: () => {},
|
|
||||||
preRun: [
|
|
||||||
(module: DoomModule) => {
|
|
||||||
// Create /doom directory and add WAD
|
|
||||||
module.FS_createPath("/", "doom", true, true);
|
|
||||||
module.FS_createDataFile("/doom", "doom1.wad", wadArray, true, false);
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
this.module = await createDoomModule(moduleConfig);
|
|
||||||
if (!this.module) {
|
|
||||||
throw new Error("Failed to initialize DOOM module");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize DOOM
|
|
||||||
this.initDoom();
|
|
||||||
|
|
||||||
// Get framebuffer info
|
|
||||||
this.frameBufferPtr = this.module._DG_GetFrameBuffer();
|
|
||||||
this._width = this.module._DG_GetScreenWidth();
|
|
||||||
this._height = this.module._DG_GetScreenHeight();
|
|
||||||
this.initialized = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private initDoom(): void {
|
|
||||||
if (!this.module) return;
|
|
||||||
|
|
||||||
const args = ["doom", "-iwad", "/doom/doom1.wad"];
|
|
||||||
const argPtrs: number[] = [];
|
|
||||||
|
|
||||||
for (const arg of args) {
|
|
||||||
const ptr = this.module._malloc(arg.length + 1);
|
|
||||||
for (let i = 0; i < arg.length; i++) {
|
|
||||||
this.module.setValue(ptr + i, arg.charCodeAt(i), "i8");
|
|
||||||
}
|
|
||||||
this.module.setValue(ptr + arg.length, 0, "i8");
|
|
||||||
argPtrs.push(ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
const argvPtr = this.module._malloc(argPtrs.length * 4);
|
|
||||||
for (let i = 0; i < argPtrs.length; i++) {
|
|
||||||
this.module.setValue(argvPtr + i * 4, argPtrs[i]!, "i32");
|
|
||||||
}
|
|
||||||
|
|
||||||
this.module._doomgeneric_Create(args.length, argvPtr);
|
|
||||||
|
|
||||||
for (const ptr of argPtrs) {
|
|
||||||
this.module._free(ptr);
|
|
||||||
}
|
|
||||||
this.module._free(argvPtr);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run one game tick
|
|
||||||
*/
|
|
||||||
tick(): void {
|
|
||||||
if (!this.module || !this.initialized) return;
|
|
||||||
this.module._doomgeneric_Tick();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current frame as RGBA pixel data
|
|
||||||
* DOOM outputs ARGB, we convert to RGBA
|
|
||||||
*/
|
|
||||||
getFrameRGBA(): Uint8Array {
|
|
||||||
if (!this.module || !this.initialized) {
|
|
||||||
return new Uint8Array(this._width * this._height * 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pixels = this._width * this._height;
|
|
||||||
const buffer = new Uint8Array(pixels * 4);
|
|
||||||
|
|
||||||
for (let i = 0; i < pixels; i++) {
|
|
||||||
const argb = this.module.getValue(this.frameBufferPtr + i * 4, "i32");
|
|
||||||
const offset = i * 4;
|
|
||||||
buffer[offset + 0] = (argb >> 16) & 0xff; // R
|
|
||||||
buffer[offset + 1] = (argb >> 8) & 0xff; // G
|
|
||||||
buffer[offset + 2] = argb & 0xff; // B
|
|
||||||
buffer[offset + 3] = 255; // A
|
|
||||||
}
|
|
||||||
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Push a key event
|
|
||||||
*/
|
|
||||||
pushKey(pressed: boolean, key: number): void {
|
|
||||||
if (!this.module || !this.initialized) return;
|
|
||||||
this.module._DG_PushKeyEvent(pressed ? 1 : 0, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
isInitialized(): boolean {
|
|
||||||
return this.initialized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
/**
|
|
||||||
* DOOM key codes (from doomkeys.h)
|
|
||||||
*/
|
|
||||||
export const DoomKeys = {
|
|
||||||
KEY_RIGHTARROW: 0xae,
|
|
||||||
KEY_LEFTARROW: 0xac,
|
|
||||||
KEY_UPARROW: 0xad,
|
|
||||||
KEY_DOWNARROW: 0xaf,
|
|
||||||
KEY_STRAFE_L: 0xa0,
|
|
||||||
KEY_STRAFE_R: 0xa1,
|
|
||||||
KEY_USE: 0xa2,
|
|
||||||
KEY_FIRE: 0xa3,
|
|
||||||
KEY_ESCAPE: 27,
|
|
||||||
KEY_ENTER: 13,
|
|
||||||
KEY_TAB: 9,
|
|
||||||
KEY_F1: 0x80 + 0x3b,
|
|
||||||
KEY_F2: 0x80 + 0x3c,
|
|
||||||
KEY_F3: 0x80 + 0x3d,
|
|
||||||
KEY_F4: 0x80 + 0x3e,
|
|
||||||
KEY_F5: 0x80 + 0x3f,
|
|
||||||
KEY_F6: 0x80 + 0x40,
|
|
||||||
KEY_F7: 0x80 + 0x41,
|
|
||||||
KEY_F8: 0x80 + 0x42,
|
|
||||||
KEY_F9: 0x80 + 0x43,
|
|
||||||
KEY_F10: 0x80 + 0x44,
|
|
||||||
KEY_F11: 0x80 + 0x57,
|
|
||||||
KEY_F12: 0x80 + 0x58,
|
|
||||||
KEY_BACKSPACE: 127,
|
|
||||||
KEY_PAUSE: 0xff,
|
|
||||||
KEY_EQUALS: 0x3d,
|
|
||||||
KEY_MINUS: 0x2d,
|
|
||||||
KEY_RSHIFT: 0x80 + 0x36,
|
|
||||||
KEY_RCTRL: 0x80 + 0x1d,
|
|
||||||
KEY_RALT: 0x80 + 0x38,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
import { Key, matchesKey, parseKey } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map terminal key input to DOOM key codes
|
|
||||||
* Supports both raw terminal input and Kitty protocol sequences
|
|
||||||
*/
|
|
||||||
export function mapKeyToDoom(data: string): number[] {
|
|
||||||
// Arrow keys
|
|
||||||
if (matchesKey(data, Key.up)) return [DoomKeys.KEY_UPARROW];
|
|
||||||
if (matchesKey(data, Key.down)) return [DoomKeys.KEY_DOWNARROW];
|
|
||||||
if (matchesKey(data, Key.right)) return [DoomKeys.KEY_RIGHTARROW];
|
|
||||||
if (matchesKey(data, Key.left)) return [DoomKeys.KEY_LEFTARROW];
|
|
||||||
|
|
||||||
// WASD - check both raw char and Kitty sequences
|
|
||||||
if (data === "w" || matchesKey(data, "w")) return [DoomKeys.KEY_UPARROW];
|
|
||||||
if (data === "W" || matchesKey(data, Key.shift("w"))) return [DoomKeys.KEY_UPARROW, DoomKeys.KEY_RSHIFT];
|
|
||||||
if (data === "s" || matchesKey(data, "s")) return [DoomKeys.KEY_DOWNARROW];
|
|
||||||
if (data === "S" || matchesKey(data, Key.shift("s"))) return [DoomKeys.KEY_DOWNARROW, DoomKeys.KEY_RSHIFT];
|
|
||||||
if (data === "a" || matchesKey(data, "a")) return [DoomKeys.KEY_STRAFE_L];
|
|
||||||
if (data === "A" || matchesKey(data, Key.shift("a"))) return [DoomKeys.KEY_STRAFE_L, DoomKeys.KEY_RSHIFT];
|
|
||||||
if (data === "d" || matchesKey(data, "d")) return [DoomKeys.KEY_STRAFE_R];
|
|
||||||
if (data === "D" || matchesKey(data, Key.shift("d"))) return [DoomKeys.KEY_STRAFE_R, DoomKeys.KEY_RSHIFT];
|
|
||||||
|
|
||||||
// Fire - F key
|
|
||||||
if (data === "f" || data === "F" || matchesKey(data, "f") || matchesKey(data, Key.shift("f"))) {
|
|
||||||
return [DoomKeys.KEY_FIRE];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use/Open
|
|
||||||
if (data === " " || matchesKey(data, Key.space)) return [DoomKeys.KEY_USE];
|
|
||||||
|
|
||||||
// Menu/UI keys
|
|
||||||
if (matchesKey(data, Key.enter)) return [DoomKeys.KEY_ENTER];
|
|
||||||
if (matchesKey(data, Key.escape)) return [DoomKeys.KEY_ESCAPE];
|
|
||||||
if (matchesKey(data, Key.tab)) return [DoomKeys.KEY_TAB];
|
|
||||||
if (matchesKey(data, Key.backspace)) return [DoomKeys.KEY_BACKSPACE];
|
|
||||||
|
|
||||||
// Ctrl keys (except Ctrl+C) = fire (legacy support)
|
|
||||||
const parsed = parseKey(data);
|
|
||||||
if (parsed?.startsWith("ctrl+") && parsed !== "ctrl+c") {
|
|
||||||
return [DoomKeys.KEY_FIRE];
|
|
||||||
}
|
|
||||||
if (data.length === 1 && data.charCodeAt(0) < 32 && data !== "\x03") {
|
|
||||||
return [DoomKeys.KEY_FIRE];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weapon selection (0-9)
|
|
||||||
if (data >= "0" && data <= "9") return [data.charCodeAt(0)];
|
|
||||||
|
|
||||||
// Plus/minus for screen size
|
|
||||||
if (data === "+" || data === "=") return [DoomKeys.KEY_EQUALS];
|
|
||||||
if (data === "-") return [DoomKeys.KEY_MINUS];
|
|
||||||
|
|
||||||
// Y/N for prompts
|
|
||||||
if (data === "y" || data === "Y" || matchesKey(data, "y") || matchesKey(data, Key.shift("y"))) {
|
|
||||||
return ["y".charCodeAt(0)];
|
|
||||||
}
|
|
||||||
if (data === "n" || data === "N" || matchesKey(data, "n") || matchesKey(data, Key.shift("n"))) {
|
|
||||||
return ["n".charCodeAt(0)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other printable characters (for cheats)
|
|
||||||
if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
||||||
return [data.toLowerCase().charCodeAt(0)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
@ -1,152 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Build DOOM for pi-doom using doomgeneric and Emscripten
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
||||||
DOOM_DIR="$PROJECT_ROOT/doom"
|
|
||||||
BUILD_DIR="$PROJECT_ROOT/doom/build"
|
|
||||||
|
|
||||||
echo "=== pi-doom Build Script ==="
|
|
||||||
|
|
||||||
# Check for emcc
|
|
||||||
if ! command -v emcc &> /dev/null; then
|
|
||||||
echo "Error: Emscripten (emcc) not found!"
|
|
||||||
echo ""
|
|
||||||
echo "Install via Homebrew:"
|
|
||||||
echo " brew install emscripten"
|
|
||||||
echo ""
|
|
||||||
echo "Or manually:"
|
|
||||||
echo " git clone https://github.com/emscripten-core/emsdk.git ~/emsdk"
|
|
||||||
echo " cd ~/emsdk && ./emsdk install latest && ./emsdk activate latest"
|
|
||||||
echo " source ~/emsdk/emsdk_env.sh"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clone doomgeneric if not present
|
|
||||||
if [ ! -d "$DOOM_DIR/doomgeneric" ]; then
|
|
||||||
echo "Cloning doomgeneric..."
|
|
||||||
cd "$DOOM_DIR"
|
|
||||||
git clone https://github.com/ozkl/doomgeneric.git
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create build directory
|
|
||||||
mkdir -p "$BUILD_DIR"
|
|
||||||
|
|
||||||
# Copy our platform file
|
|
||||||
cp "$DOOM_DIR/doomgeneric_pi.c" "$DOOM_DIR/doomgeneric/doomgeneric/"
|
|
||||||
|
|
||||||
echo "Compiling DOOM to WebAssembly..."
|
|
||||||
cd "$DOOM_DIR/doomgeneric/doomgeneric"
|
|
||||||
|
|
||||||
# Resolution - 640x400 is doomgeneric default, good balance of speed/quality
|
|
||||||
RESX=${DOOM_RESX:-640}
|
|
||||||
RESY=${DOOM_RESY:-400}
|
|
||||||
|
|
||||||
echo "Resolution: ${RESX}x${RESY}"
|
|
||||||
|
|
||||||
# Compile with Emscripten (no sound)
|
|
||||||
emcc -O2 \
|
|
||||||
-s WASM=1 \
|
|
||||||
-s EXPORTED_FUNCTIONS="['_doomgeneric_Create','_doomgeneric_Tick','_DG_GetFrameBuffer','_DG_GetScreenWidth','_DG_GetScreenHeight','_DG_PushKeyEvent','_malloc','_free']" \
|
|
||||||
-s EXPORTED_RUNTIME_METHODS="['ccall','cwrap','getValue','setValue','FS']" \
|
|
||||||
-s ALLOW_MEMORY_GROWTH=1 \
|
|
||||||
-s INITIAL_MEMORY=33554432 \
|
|
||||||
-s MODULARIZE=1 \
|
|
||||||
-s EXPORT_NAME="createDoomModule" \
|
|
||||||
-s ENVIRONMENT='node' \
|
|
||||||
-s FILESYSTEM=1 \
|
|
||||||
-s FORCE_FILESYSTEM=1 \
|
|
||||||
-s EXIT_RUNTIME=0 \
|
|
||||||
-s NO_EXIT_RUNTIME=1 \
|
|
||||||
-DDOOMGENERIC_RESX=$RESX \
|
|
||||||
-DDOOMGENERIC_RESY=$RESY \
|
|
||||||
-I. \
|
|
||||||
am_map.c \
|
|
||||||
d_event.c \
|
|
||||||
d_items.c \
|
|
||||||
d_iwad.c \
|
|
||||||
d_loop.c \
|
|
||||||
d_main.c \
|
|
||||||
d_mode.c \
|
|
||||||
d_net.c \
|
|
||||||
doomdef.c \
|
|
||||||
doomgeneric.c \
|
|
||||||
doomgeneric_pi.c \
|
|
||||||
doomstat.c \
|
|
||||||
dstrings.c \
|
|
||||||
f_finale.c \
|
|
||||||
f_wipe.c \
|
|
||||||
g_game.c \
|
|
||||||
hu_lib.c \
|
|
||||||
hu_stuff.c \
|
|
||||||
i_cdmus.c \
|
|
||||||
i_input.c \
|
|
||||||
i_endoom.c \
|
|
||||||
i_joystick.c \
|
|
||||||
i_scale.c \
|
|
||||||
i_sound.c \
|
|
||||||
i_system.c \
|
|
||||||
i_timer.c \
|
|
||||||
i_video.c \
|
|
||||||
icon.c \
|
|
||||||
info.c \
|
|
||||||
m_argv.c \
|
|
||||||
m_bbox.c \
|
|
||||||
m_cheat.c \
|
|
||||||
m_config.c \
|
|
||||||
m_controls.c \
|
|
||||||
m_fixed.c \
|
|
||||||
m_menu.c \
|
|
||||||
m_misc.c \
|
|
||||||
m_random.c \
|
|
||||||
memio.c \
|
|
||||||
p_ceilng.c \
|
|
||||||
p_doors.c \
|
|
||||||
p_enemy.c \
|
|
||||||
p_floor.c \
|
|
||||||
p_inter.c \
|
|
||||||
p_lights.c \
|
|
||||||
p_map.c \
|
|
||||||
p_maputl.c \
|
|
||||||
p_mobj.c \
|
|
||||||
p_plats.c \
|
|
||||||
p_pspr.c \
|
|
||||||
p_saveg.c \
|
|
||||||
p_setup.c \
|
|
||||||
p_sight.c \
|
|
||||||
p_spec.c \
|
|
||||||
p_switch.c \
|
|
||||||
p_telept.c \
|
|
||||||
p_tick.c \
|
|
||||||
p_user.c \
|
|
||||||
r_bsp.c \
|
|
||||||
r_data.c \
|
|
||||||
r_draw.c \
|
|
||||||
r_main.c \
|
|
||||||
r_plane.c \
|
|
||||||
r_segs.c \
|
|
||||||
r_sky.c \
|
|
||||||
r_things.c \
|
|
||||||
s_sound.c \
|
|
||||||
sha1.c \
|
|
||||||
sounds.c \
|
|
||||||
st_lib.c \
|
|
||||||
st_stuff.c \
|
|
||||||
statdump.c \
|
|
||||||
tables.c \
|
|
||||||
v_video.c \
|
|
||||||
w_checksum.c \
|
|
||||||
w_file.c \
|
|
||||||
w_file_stdc.c \
|
|
||||||
w_main.c \
|
|
||||||
w_wad.c \
|
|
||||||
wi_stuff.c \
|
|
||||||
z_zone.c \
|
|
||||||
dummy.c \
|
|
||||||
-o "$BUILD_DIR/doom.js"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Build complete!"
|
|
||||||
echo "Output: $BUILD_DIR/doom.js and $BUILD_DIR/doom.wasm"
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -1,72 +0,0 @@
|
||||||
/**
|
|
||||||
* pi-doom platform implementation for doomgeneric
|
|
||||||
*
|
|
||||||
* Minimal implementation - no sound, just framebuffer and input.
|
|
||||||
*/
|
|
||||||
|
|
||||||
#include "doomgeneric.h"
|
|
||||||
#include "doomkeys.h"
|
|
||||||
#include <emscripten.h>
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
// Key event queue
|
|
||||||
#define KEY_QUEUE_SIZE 256
|
|
||||||
static struct {
|
|
||||||
int pressed;
|
|
||||||
unsigned char key;
|
|
||||||
} key_queue[KEY_QUEUE_SIZE];
|
|
||||||
static int key_queue_read = 0;
|
|
||||||
static int key_queue_write = 0;
|
|
||||||
|
|
||||||
// Get the framebuffer pointer for JS to read
|
|
||||||
EMSCRIPTEN_KEEPALIVE
|
|
||||||
uint32_t *DG_GetFrameBuffer(void) { return DG_ScreenBuffer; }
|
|
||||||
|
|
||||||
// Get framebuffer dimensions
|
|
||||||
EMSCRIPTEN_KEEPALIVE
|
|
||||||
int DG_GetScreenWidth(void) { return DOOMGENERIC_RESX; }
|
|
||||||
|
|
||||||
EMSCRIPTEN_KEEPALIVE
|
|
||||||
int DG_GetScreenHeight(void) { return DOOMGENERIC_RESY; }
|
|
||||||
|
|
||||||
// Push a key event from JavaScript
|
|
||||||
EMSCRIPTEN_KEEPALIVE
|
|
||||||
void DG_PushKeyEvent(int pressed, unsigned char key) {
|
|
||||||
int next_write = (key_queue_write + 1) % KEY_QUEUE_SIZE;
|
|
||||||
if (next_write != key_queue_read) {
|
|
||||||
key_queue[key_queue_write].pressed = pressed;
|
|
||||||
key_queue[key_queue_write].key = key;
|
|
||||||
key_queue_write = next_write;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DG_Init(void) {
|
|
||||||
// Nothing to initialize
|
|
||||||
}
|
|
||||||
|
|
||||||
void DG_DrawFrame(void) {
|
|
||||||
// Frame is in DG_ScreenBuffer, JS reads via DG_GetFrameBuffer
|
|
||||||
}
|
|
||||||
|
|
||||||
void DG_SleepMs(uint32_t ms) {
|
|
||||||
// No-op - JS handles timing
|
|
||||||
(void)ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t DG_GetTicksMs(void) {
|
|
||||||
return (uint32_t)emscripten_get_now();
|
|
||||||
}
|
|
||||||
|
|
||||||
int DG_GetKey(int *pressed, unsigned char *key) {
|
|
||||||
if (key_queue_read != key_queue_write) {
|
|
||||||
*pressed = key_queue[key_queue_read].pressed;
|
|
||||||
*key = key_queue[key_queue_read].key;
|
|
||||||
key_queue_read = (key_queue_read + 1) % KEY_QUEUE_SIZE;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
void DG_SetWindowTitle(const char *title) {
|
|
||||||
(void)title;
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
/**
|
|
||||||
* DOOM Overlay Demo - Play DOOM as an overlay
|
|
||||||
*
|
|
||||||
* Usage: pi --extension ./examples/extensions/doom-overlay
|
|
||||||
*
|
|
||||||
* Commands:
|
|
||||||
* /doom-overlay - Play DOOM in an overlay (Q to pause/exit)
|
|
||||||
*
|
|
||||||
* This demonstrates that overlays can handle real-time game rendering at 35 FPS.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { DoomOverlayComponent } from "./doom-component.js";
|
|
||||||
import { DoomEngine } from "./doom-engine.js";
|
|
||||||
import { ensureWadFile } from "./wad-finder.js";
|
|
||||||
|
|
||||||
// Persistent engine instance - survives between invocations
|
|
||||||
let activeEngine: DoomEngine | null = null;
|
|
||||||
let activeWadPath: string | null = null;
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("doom-overlay", {
|
|
||||||
description: "Play DOOM as an overlay. Q to pause and exit.",
|
|
||||||
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
ctx.ui.notify("DOOM requires interactive mode", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-download WAD if not present
|
|
||||||
ctx.ui.notify("Loading DOOM...", "info");
|
|
||||||
const wad = args?.trim() ? args.trim() : await ensureWadFile();
|
|
||||||
|
|
||||||
if (!wad) {
|
|
||||||
ctx.ui.notify("Failed to download DOOM WAD file. Check your internet connection.", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reuse existing engine if same WAD, otherwise create new
|
|
||||||
let isResume = false;
|
|
||||||
if (activeEngine && activeWadPath === wad) {
|
|
||||||
ctx.ui.notify("Resuming DOOM...", "info");
|
|
||||||
isResume = true;
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify(`Loading DOOM from ${wad}...`, "info");
|
|
||||||
activeEngine = new DoomEngine(wad);
|
|
||||||
await activeEngine.init();
|
|
||||||
activeWadPath = wad;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.ui.custom(
|
|
||||||
(tui, _theme, _keybindings, done) => {
|
|
||||||
return new DoomOverlayComponent(tui, activeEngine!, () => done(undefined), isResume);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: {
|
|
||||||
width: "75%",
|
|
||||||
maxHeight: "95%",
|
|
||||||
anchor: "center",
|
|
||||||
margin: { top: 1 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.ui.notify(`Failed to load DOOM: ${error}`, "error");
|
|
||||||
activeEngine = null;
|
|
||||||
activeWadPath = null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
|
||||||
import { dirname, join, resolve } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
// Get the bundled WAD path (relative to this module)
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const BUNDLED_WAD = join(__dirname, "doom1.wad");
|
|
||||||
const WAD_URL = "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad";
|
|
||||||
|
|
||||||
const DEFAULT_WAD_PATHS = ["./doom1.wad", "./DOOM1.WAD", "~/doom1.wad", "~/.doom/doom1.wad"];
|
|
||||||
|
|
||||||
export function findWadFile(customPath?: string): string | null {
|
|
||||||
if (customPath) {
|
|
||||||
const resolved = resolve(customPath.replace(/^~/, process.env.HOME || ""));
|
|
||||||
if (existsSync(resolved)) return resolved;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bundled WAD first
|
|
||||||
if (existsSync(BUNDLED_WAD)) {
|
|
||||||
return BUNDLED_WAD;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to default paths
|
|
||||||
for (const p of DEFAULT_WAD_PATHS) {
|
|
||||||
const resolved = resolve(p.replace(/^~/, process.env.HOME || ""));
|
|
||||||
if (existsSync(resolved)) return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Download the shareware WAD if not present. Returns path or null on failure. */
|
|
||||||
export async function ensureWadFile(): Promise<string | null> {
|
|
||||||
// Check if already exists
|
|
||||||
const existing = findWadFile();
|
|
||||||
if (existing) return existing;
|
|
||||||
|
|
||||||
// Download to bundled location
|
|
||||||
try {
|
|
||||||
const response = await fetch(WAD_URL);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
const buffer = await response.arrayBuffer();
|
|
||||||
writeFileSync(BUNDLED_WAD, Buffer.from(buffer));
|
|
||||||
return BUNDLED_WAD;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
name: dynamic-resources
|
|
||||||
description: Example skill loaded from resources_discover
|
|
||||||
---
|
|
||||||
|
|
||||||
# Dynamic Resources Skill
|
|
||||||
|
|
||||||
This skill is provided by the dynamic-resources extension.
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
||||||
"name": "dynamic-resources",
|
|
||||||
"vars": {
|
|
||||||
"cyan": "#00d7ff",
|
|
||||||
"blue": "#5f87ff",
|
|
||||||
"green": "#b5bd68",
|
|
||||||
"red": "#cc6666",
|
|
||||||
"yellow": "#ffff00",
|
|
||||||
"gray": "#808080",
|
|
||||||
"dimGray": "#666666",
|
|
||||||
"darkGray": "#505050",
|
|
||||||
"accent": "#8abeb7",
|
|
||||||
"selectedBg": "#3a3a4a",
|
|
||||||
"userMsgBg": "#343541",
|
|
||||||
"toolPendingBg": "#282832",
|
|
||||||
"toolSuccessBg": "#283228",
|
|
||||||
"toolErrorBg": "#3c2828",
|
|
||||||
"customMsgBg": "#2d2838"
|
|
||||||
},
|
|
||||||
"colors": {
|
|
||||||
"accent": "accent",
|
|
||||||
"border": "blue",
|
|
||||||
"borderAccent": "cyan",
|
|
||||||
"borderMuted": "darkGray",
|
|
||||||
"success": "green",
|
|
||||||
"error": "red",
|
|
||||||
"warning": "yellow",
|
|
||||||
"muted": "gray",
|
|
||||||
"dim": "dimGray",
|
|
||||||
"text": "",
|
|
||||||
"thinkingText": "gray",
|
|
||||||
"selectedBg": "selectedBg",
|
|
||||||
"userMessageBg": "userMsgBg",
|
|
||||||
"userMessageText": "",
|
|
||||||
"customMessageBg": "customMsgBg",
|
|
||||||
"customMessageText": "",
|
|
||||||
"customMessageLabel": "#9575cd",
|
|
||||||
"toolPendingBg": "toolPendingBg",
|
|
||||||
"toolSuccessBg": "toolSuccessBg",
|
|
||||||
"toolErrorBg": "toolErrorBg",
|
|
||||||
"toolTitle": "",
|
|
||||||
"toolOutput": "gray",
|
|
||||||
"mdHeading": "#f0c674",
|
|
||||||
"mdLink": "#81a2be",
|
|
||||||
"mdLinkUrl": "dimGray",
|
|
||||||
"mdCode": "accent",
|
|
||||||
"mdCodeBlock": "green",
|
|
||||||
"mdCodeBlockBorder": "gray",
|
|
||||||
"mdQuote": "gray",
|
|
||||||
"mdQuoteBorder": "gray",
|
|
||||||
"mdHr": "gray",
|
|
||||||
"mdListBullet": "accent",
|
|
||||||
"toolDiffAdded": "green",
|
|
||||||
"toolDiffRemoved": "red",
|
|
||||||
"toolDiffContext": "gray",
|
|
||||||
"syntaxComment": "#6A9955",
|
|
||||||
"syntaxKeyword": "#569CD6",
|
|
||||||
"syntaxFunction": "#DCDCAA",
|
|
||||||
"syntaxVariable": "#9CDCFE",
|
|
||||||
"syntaxString": "#CE9178",
|
|
||||||
"syntaxNumber": "#B5CEA8",
|
|
||||||
"syntaxType": "#4EC9B0",
|
|
||||||
"syntaxOperator": "#D4D4D4",
|
|
||||||
"syntaxPunctuation": "#D4D4D4",
|
|
||||||
"thinkingOff": "darkGray",
|
|
||||||
"thinkingMinimal": "#6e6e6e",
|
|
||||||
"thinkingLow": "#5f87af",
|
|
||||||
"thinkingMedium": "#81a2be",
|
|
||||||
"thinkingHigh": "#b294bb",
|
|
||||||
"thinkingXhigh": "#d183e8",
|
|
||||||
"bashMode": "green"
|
|
||||||
},
|
|
||||||
"export": {
|
|
||||||
"pageBg": "#18181e",
|
|
||||||
"cardBg": "#1e1e24",
|
|
||||||
"infoBg": "#3c3728"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
---
|
|
||||||
description: Example prompt template loaded from resources_discover
|
|
||||||
---
|
|
||||||
|
|
||||||
Summarize the current repository structure and mention any build or test commands.
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import { dirname, join } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
const baseDir = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("resources_discover", () => {
|
|
||||||
return {
|
|
||||||
skillPaths: [join(baseDir, "SKILL.md")],
|
|
||||||
promptPaths: [join(baseDir, "dynamic.md")],
|
|
||||||
themePaths: [join(baseDir, "dynamic.json")],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
/**
|
|
||||||
* Dynamic Tools Extension
|
|
||||||
*
|
|
||||||
* Demonstrates registering tools after session initialization.
|
|
||||||
*
|
|
||||||
* - Registers one tool during session_start
|
|
||||||
* - Registers additional tools at runtime via /add-echo-tool <name>
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
const ECHO_PARAMS = Type.Object({
|
|
||||||
message: Type.String({ description: "Message to echo" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
function normalizeToolName(input: string): string | undefined {
|
|
||||||
const trimmed = input.trim().toLowerCase();
|
|
||||||
if (!trimmed) return undefined;
|
|
||||||
if (!/^[a-z0-9_]+$/.test(trimmed)) return undefined;
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function dynamicToolsExtension(pi: ExtensionAPI) {
|
|
||||||
const registeredToolNames = new Set<string>();
|
|
||||||
|
|
||||||
const registerEchoTool = (name: string, label: string, prefix: string): boolean => {
|
|
||||||
if (registeredToolNames.has(name)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
registeredToolNames.add(name);
|
|
||||||
pi.registerTool({
|
|
||||||
name,
|
|
||||||
label,
|
|
||||||
description: `Echo a message with prefix: ${prefix}`,
|
|
||||||
promptSnippet: `Echo back user-provided text with ${prefix.trim()} prefix`,
|
|
||||||
promptGuidelines: ["Use this tool when the user asks for exact echo output."],
|
|
||||||
parameters: ECHO_PARAMS,
|
|
||||||
async execute(_toolCallId, params) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `${prefix}${params.message}` }],
|
|
||||||
details: { tool: name, prefix },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
registerEchoTool("echo_session", "Echo Session", "[session] ");
|
|
||||||
ctx.ui.notify("Registered dynamic tool: echo_session", "info");
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("add-echo-tool", {
|
|
||||||
description: "Register a new echo tool dynamically: /add-echo-tool <tool_name>",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
const toolName = normalizeToolName(args);
|
|
||||||
if (!toolName) {
|
|
||||||
ctx.ui.notify("Usage: /add-echo-tool <tool_name> (lowercase, numbers, underscores)", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const created = registerEchoTool(toolName, `Echo ${toolName}`, `[${toolName}] `);
|
|
||||||
if (!created) {
|
|
||||||
ctx.ui.notify(`Tool already registered: ${toolName}`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.ui.notify(`Registered dynamic tool: ${toolName}`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* Inter-extension event bus example.
|
|
||||||
*
|
|
||||||
* Shows pi.events for communication between extensions. One extension
|
|
||||||
* can emit events that other extensions listen to.
|
|
||||||
*
|
|
||||||
* Usage: /emit [event-name] [data] - emit an event on the bus
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Store ctx for use in event handler
|
|
||||||
let currentCtx: ExtensionContext | undefined;
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
currentCtx = ctx;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for events from other extensions
|
|
||||||
pi.events.on("my:notification", (data) => {
|
|
||||||
const { message, from } = data as { message: string; from: string };
|
|
||||||
currentCtx?.ui.notify(`Event from ${from}: ${message}`, "info");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Command to emit events (emits "my:notification" which the listener above receives)
|
|
||||||
pi.registerCommand("emit", {
|
|
||||||
description: "Emit my:notification event (usage: /emit message)",
|
|
||||||
handler: async (args, _ctx) => {
|
|
||||||
const message = args.trim() || "hello";
|
|
||||||
pi.events.emit("my:notification", { message, from: "/emit command" });
|
|
||||||
// Listener above will show the notification
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example: emit on session start
|
|
||||||
pi.on("session_start", async () => {
|
|
||||||
pi.events.emit("my:notification", {
|
|
||||||
message: "Session started",
|
|
||||||
from: "event-bus-example",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
/**
|
|
||||||
* File Trigger Extension
|
|
||||||
*
|
|
||||||
* Watches a trigger file and injects its contents into the conversation.
|
|
||||||
* Useful for external systems to send messages to the agent.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* echo "Run the tests" > /tmp/agent-trigger.txt
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
const triggerFile = "/tmp/agent-trigger.txt";
|
|
||||||
|
|
||||||
fs.watch(triggerFile, () => {
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(triggerFile, "utf-8").trim();
|
|
||||||
if (content) {
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "file-trigger",
|
|
||||||
content: `External trigger: ${content}`,
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
{ triggerTurn: true }, // triggerTurn - get LLM to respond
|
|
||||||
);
|
|
||||||
fs.writeFileSync(triggerFile, ""); // Clear after reading
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File might not exist yet
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ctx.hasUI) {
|
|
||||||
ctx.ui.notify(`Watching ${triggerFile}`, "info");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
/**
|
|
||||||
* Git Checkpoint Extension
|
|
||||||
*
|
|
||||||
* Creates git stash checkpoints at each turn so /fork can restore code state.
|
|
||||||
* When forking, offers to restore code to that point in history.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const checkpoints = new Map<string, string>();
|
|
||||||
let currentEntryId: string | undefined;
|
|
||||||
|
|
||||||
// Track the current entry ID when user messages are saved
|
|
||||||
pi.on("tool_result", async (_event, ctx) => {
|
|
||||||
const leaf = ctx.sessionManager.getLeafEntry();
|
|
||||||
if (leaf) currentEntryId = leaf.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("turn_start", async () => {
|
|
||||||
// Create a git stash entry before LLM makes changes
|
|
||||||
const { stdout } = await pi.exec("git", ["stash", "create"]);
|
|
||||||
const ref = stdout.trim();
|
|
||||||
if (ref && currentEntryId) {
|
|
||||||
checkpoints.set(currentEntryId, ref);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_before_fork", async (event, ctx) => {
|
|
||||||
const ref = checkpoints.get(event.entryId);
|
|
||||||
if (!ref) return;
|
|
||||||
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
// In non-interactive mode, don't restore automatically
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Restore code state?", [
|
|
||||||
"Yes, restore code to that point",
|
|
||||||
"No, keep current code",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (choice?.startsWith("Yes")) {
|
|
||||||
await pi.exec("git", ["stash", "apply", ref]);
|
|
||||||
ctx.ui.notify("Code restored to checkpoint", "info");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("agent_end", async () => {
|
|
||||||
// Clear checkpoints after agent completes
|
|
||||||
checkpoints.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/**
|
|
||||||
* Handoff extension - transfer context to a new focused session
|
|
||||||
*
|
|
||||||
* Instead of compacting (which is lossy), handoff extracts what matters
|
|
||||||
* for your next task and creates a new session with a generated prompt.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* /handoff now implement this for teams as well
|
|
||||||
* /handoff execute phase one of the plan
|
|
||||||
* /handoff check other places that need this fix
|
|
||||||
*
|
|
||||||
* The generated prompt appears as a draft in the editor for review/editing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a context transfer assistant. Given a conversation history and the user's goal for a new thread, generate a focused prompt that:
|
|
||||||
|
|
||||||
1. Summarizes relevant context from the conversation (decisions made, approaches taken, key findings)
|
|
||||||
2. Lists any relevant files that were discussed or modified
|
|
||||||
3. Clearly states the next task based on the user's goal
|
|
||||||
4. Is self-contained - the new thread should be able to proceed without the old conversation
|
|
||||||
|
|
||||||
Format your response as a prompt the user can send to start the new thread. Be concise but include all necessary context. Do not include any preamble like "Here's the prompt" - just output the prompt itself.
|
|
||||||
|
|
||||||
Example output format:
|
|
||||||
## Context
|
|
||||||
We've been working on X. Key decisions:
|
|
||||||
- Decision 1
|
|
||||||
- Decision 2
|
|
||||||
|
|
||||||
Files involved:
|
|
||||||
- path/to/file1.ts
|
|
||||||
- path/to/file2.ts
|
|
||||||
|
|
||||||
## Task
|
|
||||||
[Clear description of what to do next based on user's goal]`;
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("handoff", {
|
|
||||||
description: "Transfer context to a new focused session",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
ctx.ui.notify("handoff requires interactive mode", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.model) {
|
|
||||||
ctx.ui.notify("No model selected", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const goal = args.trim();
|
|
||||||
if (!goal) {
|
|
||||||
ctx.ui.notify("Usage: /handoff <goal for new thread>", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather conversation context from current branch
|
|
||||||
const branch = ctx.sessionManager.getBranch();
|
|
||||||
const messages = branch
|
|
||||||
.filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message")
|
|
||||||
.map((entry) => entry.message);
|
|
||||||
|
|
||||||
if (messages.length === 0) {
|
|
||||||
ctx.ui.notify("No conversation to hand off", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to LLM format and serialize
|
|
||||||
const llmMessages = convertToLlm(messages);
|
|
||||||
const conversationText = serializeConversation(llmMessages);
|
|
||||||
const currentSessionFile = ctx.sessionManager.getSessionFile();
|
|
||||||
|
|
||||||
// Generate the handoff prompt with loader UI
|
|
||||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
||||||
const loader = new BorderedLoader(tui, theme, `Generating handoff prompt...`);
|
|
||||||
loader.onAbort = () => done(null);
|
|
||||||
|
|
||||||
const doGenerate = async () => {
|
|
||||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
|
||||||
|
|
||||||
const userMessage: Message = {
|
|
||||||
role: "user",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `## Conversation History\n\n${conversationText}\n\n## User's Goal for New Thread\n\n${goal}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await complete(
|
|
||||||
ctx.model!,
|
|
||||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
||||||
{ apiKey, signal: loader.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.stopReason === "aborted") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
doGenerate()
|
|
||||||
.then(done)
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Handoff generation failed:", err);
|
|
||||||
done(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
return loader;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
ctx.ui.notify("Cancelled", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let user edit the generated prompt
|
|
||||||
const editedPrompt = await ctx.ui.editor("Edit handoff prompt", result);
|
|
||||||
|
|
||||||
if (editedPrompt === undefined) {
|
|
||||||
ctx.ui.notify("Cancelled", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new session with parent tracking
|
|
||||||
const newSessionResult = await ctx.newSession({
|
|
||||||
parentSession: currentSessionFile,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newSessionResult.cancelled) {
|
|
||||||
ctx.ui.notify("New session cancelled", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the edited prompt in the main editor for submission
|
|
||||||
ctx.ui.setEditorText(editedPrompt);
|
|
||||||
ctx.ui.notify("Handoff ready. Submit when ready.", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
/**
|
|
||||||
* Hello Tool - Minimal custom tool example
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Type } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "hello",
|
|
||||||
label: "Hello",
|
|
||||||
description: "A simple greeting tool",
|
|
||||||
parameters: Type.Object({
|
|
||||||
name: Type.String({ description: "Name to greet" }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
|
||||||
const { name } = params as { name: string };
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Hello, ${name}!` }],
|
|
||||||
details: { greeted: name },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
/**
|
|
||||||
* Inline Bash Extension - expands inline bash commands in user prompts.
|
|
||||||
*
|
|
||||||
* Start pi with this extension:
|
|
||||||
* pi -e ./examples/extensions/inline-bash.ts
|
|
||||||
*
|
|
||||||
* Then type prompts with inline bash:
|
|
||||||
* What's in !{pwd}?
|
|
||||||
* The current branch is !{git branch --show-current} and status: !{git status --short}
|
|
||||||
* My node version is !{node --version}
|
|
||||||
*
|
|
||||||
* The !{command} patterns are executed and replaced with their output before
|
|
||||||
* the prompt is sent to the agent.
|
|
||||||
*
|
|
||||||
* Note: Regular !command syntax (whole-line bash) is preserved and works as before.
|
|
||||||
*/
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const PATTERN = /!\{([^}]+)\}/g;
|
|
||||||
const TIMEOUT_MS = 30000;
|
|
||||||
|
|
||||||
pi.on("input", async (event, ctx) => {
|
|
||||||
const text = event.text;
|
|
||||||
|
|
||||||
// Don't process if it's a whole-line bash command (starts with !)
|
|
||||||
// This preserves the existing !command behavior
|
|
||||||
if (text.trimStart().startsWith("!") && !text.trimStart().startsWith("!{")) {
|
|
||||||
return { action: "continue" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there are any inline bash patterns
|
|
||||||
if (!PATTERN.test(text)) {
|
|
||||||
return { action: "continue" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset regex state after test()
|
|
||||||
PATTERN.lastIndex = 0;
|
|
||||||
|
|
||||||
let result = text;
|
|
||||||
const expansions: Array<{ command: string; output: string; error?: string }> = [];
|
|
||||||
|
|
||||||
// Find all matches first (to avoid issues with replacing while iterating)
|
|
||||||
const matches: Array<{ full: string; command: string }> = [];
|
|
||||||
let match = PATTERN.exec(text);
|
|
||||||
while (match) {
|
|
||||||
matches.push({ full: match[0], command: match[1] });
|
|
||||||
match = PATTERN.exec(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute each command and collect results
|
|
||||||
for (const { full, command } of matches) {
|
|
||||||
try {
|
|
||||||
const bashResult = await pi.exec("bash", ["-c", command], {
|
|
||||||
timeout: TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const output = bashResult.stdout || bashResult.stderr || "";
|
|
||||||
const trimmed = output.trim();
|
|
||||||
|
|
||||||
if (bashResult.code !== 0 && bashResult.stderr) {
|
|
||||||
expansions.push({
|
|
||||||
command,
|
|
||||||
output: trimmed,
|
|
||||||
error: `exit code ${bashResult.code}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
expansions.push({ command, output: trimmed });
|
|
||||||
}
|
|
||||||
|
|
||||||
result = result.replace(full, trimmed);
|
|
||||||
} catch (err) {
|
|
||||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
||||||
expansions.push({ command, output: "", error: errorMsg });
|
|
||||||
result = result.replace(full, `[error: ${errorMsg}]`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show what was expanded (if UI available)
|
|
||||||
if (ctx.hasUI && expansions.length > 0) {
|
|
||||||
const summary = expansions
|
|
||||||
.map((e) => {
|
|
||||||
const status = e.error ? ` (${e.error})` : "";
|
|
||||||
const preview = e.output.length > 50 ? `${e.output.slice(0, 50)}...` : e.output;
|
|
||||||
return `!{${e.command}}${status} -> "${preview}"`;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
ctx.ui.notify(`Expanded ${expansions.length} inline command(s):\n${summary}`, "info");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: "transform", text: result, images: event.images };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
/**
|
|
||||||
* Input Transform Example - demonstrates the `input` event for intercepting user input.
|
|
||||||
*
|
|
||||||
* Start pi with this extension:
|
|
||||||
* pi -e ./examples/extensions/input-transform.ts
|
|
||||||
*
|
|
||||||
* Then type these inside pi:
|
|
||||||
* ?quick What is TypeScript? → "Respond briefly: What is TypeScript?"
|
|
||||||
* ping → "pong" (instant, no LLM)
|
|
||||||
* time → current time (instant, no LLM)
|
|
||||||
*/
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("input", async (event, ctx) => {
|
|
||||||
// Source-based logic: skip processing for extension-injected messages
|
|
||||||
if (event.source === "extension") {
|
|
||||||
return { action: "continue" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform: ?quick prefix for brief responses
|
|
||||||
if (event.text.startsWith("?quick ")) {
|
|
||||||
const query = event.text.slice(7).trim();
|
|
||||||
if (!query) {
|
|
||||||
ctx.ui.notify("Usage: ?quick <question>", "warning");
|
|
||||||
return { action: "handled" };
|
|
||||||
}
|
|
||||||
return { action: "transform", text: `Respond briefly in 1-2 sentences: ${query}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle: instant responses without LLM (extension shows its own feedback)
|
|
||||||
if (event.text.toLowerCase() === "ping") {
|
|
||||||
ctx.ui.notify("pong", "info");
|
|
||||||
return { action: "handled" };
|
|
||||||
}
|
|
||||||
if (event.text.toLowerCase() === "time") {
|
|
||||||
ctx.ui.notify(new Date().toLocaleString(), "info");
|
|
||||||
return { action: "handled" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: "continue" };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
/**
|
|
||||||
* Interactive Shell Commands Extension
|
|
||||||
*
|
|
||||||
* Enables running interactive commands (vim, git rebase -i, htop, etc.)
|
|
||||||
* with full terminal access. The TUI suspends while they run.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e examples/extensions/interactive-shell.ts
|
|
||||||
*
|
|
||||||
* !vim file.txt # Auto-detected as interactive
|
|
||||||
* !i any-command # Force interactive mode with !i prefix
|
|
||||||
* !git rebase -i HEAD~3
|
|
||||||
* !htop
|
|
||||||
*
|
|
||||||
* Configuration via environment variables:
|
|
||||||
* INTERACTIVE_COMMANDS - Additional commands (comma-separated)
|
|
||||||
* INTERACTIVE_EXCLUDE - Commands to exclude (comma-separated)
|
|
||||||
*
|
|
||||||
* Note: This only intercepts user `!` commands, not agent bash tool calls.
|
|
||||||
* If the agent runs an interactive command, it will fail (which is fine).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// Default interactive commands - editors, pagers, git ops, TUIs
|
|
||||||
const DEFAULT_INTERACTIVE_COMMANDS = [
|
|
||||||
// Editors
|
|
||||||
"vim",
|
|
||||||
"nvim",
|
|
||||||
"vi",
|
|
||||||
"nano",
|
|
||||||
"emacs",
|
|
||||||
"pico",
|
|
||||||
"micro",
|
|
||||||
"helix",
|
|
||||||
"hx",
|
|
||||||
"kak",
|
|
||||||
// Pagers
|
|
||||||
"less",
|
|
||||||
"more",
|
|
||||||
"most",
|
|
||||||
// Git interactive
|
|
||||||
"git commit",
|
|
||||||
"git rebase",
|
|
||||||
"git merge",
|
|
||||||
"git cherry-pick",
|
|
||||||
"git revert",
|
|
||||||
"git add -p",
|
|
||||||
"git add --patch",
|
|
||||||
"git add -i",
|
|
||||||
"git add --interactive",
|
|
||||||
"git stash -p",
|
|
||||||
"git stash --patch",
|
|
||||||
"git reset -p",
|
|
||||||
"git reset --patch",
|
|
||||||
"git checkout -p",
|
|
||||||
"git checkout --patch",
|
|
||||||
"git difftool",
|
|
||||||
"git mergetool",
|
|
||||||
// System monitors
|
|
||||||
"htop",
|
|
||||||
"top",
|
|
||||||
"btop",
|
|
||||||
"glances",
|
|
||||||
// File managers
|
|
||||||
"ranger",
|
|
||||||
"nnn",
|
|
||||||
"lf",
|
|
||||||
"mc",
|
|
||||||
"vifm",
|
|
||||||
// Git TUIs
|
|
||||||
"tig",
|
|
||||||
"lazygit",
|
|
||||||
"gitui",
|
|
||||||
// Fuzzy finders
|
|
||||||
"fzf",
|
|
||||||
"sk",
|
|
||||||
// Remote sessions
|
|
||||||
"ssh",
|
|
||||||
"telnet",
|
|
||||||
"mosh",
|
|
||||||
// Database clients
|
|
||||||
"psql",
|
|
||||||
"mysql",
|
|
||||||
"sqlite3",
|
|
||||||
"mongosh",
|
|
||||||
"redis-cli",
|
|
||||||
// Kubernetes/Docker
|
|
||||||
"kubectl edit",
|
|
||||||
"kubectl exec -it",
|
|
||||||
"docker exec -it",
|
|
||||||
"docker run -it",
|
|
||||||
// Other
|
|
||||||
"tmux",
|
|
||||||
"screen",
|
|
||||||
"ncdu",
|
|
||||||
];
|
|
||||||
|
|
||||||
function getInteractiveCommands(): string[] {
|
|
||||||
const additional =
|
|
||||||
process.env.INTERACTIVE_COMMANDS?.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean) ?? [];
|
|
||||||
const excluded = new Set(process.env.INTERACTIVE_EXCLUDE?.split(",").map((s) => s.trim().toLowerCase()) ?? []);
|
|
||||||
return [...DEFAULT_INTERACTIVE_COMMANDS, ...additional].filter((cmd) => !excluded.has(cmd.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInteractiveCommand(command: string): boolean {
|
|
||||||
const trimmed = command.trim().toLowerCase();
|
|
||||||
const commands = getInteractiveCommands();
|
|
||||||
|
|
||||||
for (const cmd of commands) {
|
|
||||||
const cmdLower = cmd.toLowerCase();
|
|
||||||
// Match at start
|
|
||||||
if (trimmed === cmdLower || trimmed.startsWith(`${cmdLower} `) || trimmed.startsWith(`${cmdLower}\t`)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Match after pipe: "cat file | less"
|
|
||||||
const pipeIdx = trimmed.lastIndexOf("|");
|
|
||||||
if (pipeIdx !== -1) {
|
|
||||||
const afterPipe = trimmed.slice(pipeIdx + 1).trim();
|
|
||||||
if (afterPipe === cmdLower || afterPipe.startsWith(`${cmdLower} `)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("user_bash", async (event, ctx) => {
|
|
||||||
let command = event.command;
|
|
||||||
let forceInteractive = false;
|
|
||||||
|
|
||||||
// Check for !i prefix (command comes without the leading !)
|
|
||||||
// The prefix parsing happens before this event, so we check if command starts with "i "
|
|
||||||
if (command.startsWith("i ") || command.startsWith("i\t")) {
|
|
||||||
forceInteractive = true;
|
|
||||||
command = command.slice(2).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldBeInteractive = forceInteractive || isInteractiveCommand(command);
|
|
||||||
if (!shouldBeInteractive) {
|
|
||||||
return; // Let normal handling proceed
|
|
||||||
}
|
|
||||||
|
|
||||||
// No UI available (print mode, RPC, etc.)
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return {
|
|
||||||
result: { output: "(interactive commands require TUI)", exitCode: 1, cancelled: false, truncated: false },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use ctx.ui.custom() to get TUI access, then run the command
|
|
||||||
const exitCode = await ctx.ui.custom<number | null>((tui, _theme, _kb, done) => {
|
|
||||||
// Stop TUI to release terminal
|
|
||||||
tui.stop();
|
|
||||||
|
|
||||||
// Clear screen
|
|
||||||
process.stdout.write("\x1b[2J\x1b[H");
|
|
||||||
|
|
||||||
// Run command with full terminal access
|
|
||||||
const shell = process.env.SHELL || "/bin/sh";
|
|
||||||
const result = spawnSync(shell, ["-c", command], {
|
|
||||||
stdio: "inherit",
|
|
||||||
env: process.env,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restart TUI
|
|
||||||
tui.start();
|
|
||||||
tui.requestRender(true);
|
|
||||||
|
|
||||||
// Signal completion
|
|
||||||
done(result.status);
|
|
||||||
|
|
||||||
// Return empty component (immediately disposed since done() was called)
|
|
||||||
return { render: () => [], invalidate: () => {} };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return result to prevent default bash handling
|
|
||||||
const output =
|
|
||||||
exitCode === 0
|
|
||||||
? "(interactive command completed successfully)"
|
|
||||||
: `(interactive command exited with code ${exitCode})`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
result: {
|
|
||||||
output,
|
|
||||||
exitCode: exitCode ?? 1,
|
|
||||||
cancelled: false,
|
|
||||||
truncated: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
/**
|
|
||||||
* Syncs pi theme with macOS system appearance (dark/light mode).
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e examples/extensions/mac-system-theme.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { exec } from "node:child_process";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
async function isDarkMode(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(
|
|
||||||
"osascript -e 'tell application \"System Events\" to tell appearance preferences to return dark mode'",
|
|
||||||
);
|
|
||||||
return stdout.trim() === "true";
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
let currentTheme = (await isDarkMode()) ? "dark" : "light";
|
|
||||||
ctx.ui.setTheme(currentTheme);
|
|
||||||
|
|
||||||
intervalId = setInterval(async () => {
|
|
||||||
const newTheme = (await isDarkMode()) ? "dark" : "light";
|
|
||||||
if (newTheme !== currentTheme) {
|
|
||||||
currentTheme = newTheme;
|
|
||||||
ctx.ui.setTheme(currentTheme);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_shutdown", () => {
|
|
||||||
if (intervalId) {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
intervalId = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
/**
|
|
||||||
* Custom message rendering example.
|
|
||||||
*
|
|
||||||
* Shows how to use registerMessageRenderer to control how custom messages
|
|
||||||
* appear in the TUI, with colors, formatting, and expandable details.
|
|
||||||
*
|
|
||||||
* Usage: /status [message] - sends a status message with custom rendering
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Box, Text } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Register custom renderer for "status-update" messages
|
|
||||||
pi.registerMessageRenderer("status-update", (message, { expanded }, theme) => {
|
|
||||||
const details = message.details as { level: string; timestamp: number } | undefined;
|
|
||||||
const level = details?.level ?? "info";
|
|
||||||
|
|
||||||
// Color based on level
|
|
||||||
const color = level === "error" ? "error" : level === "warn" ? "warning" : "success";
|
|
||||||
const prefix = theme.fg(color, `[${level.toUpperCase()}]`);
|
|
||||||
|
|
||||||
let text = `${prefix} ${message.content}`;
|
|
||||||
|
|
||||||
// Show timestamp when expanded
|
|
||||||
if (expanded && details?.timestamp) {
|
|
||||||
const time = new Date(details.timestamp).toLocaleTimeString();
|
|
||||||
text += `\n${theme.fg("dim", ` at ${time}`)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use Box with customMessageBg for consistent styling
|
|
||||||
const box = new Box(1, 1, (t) => theme.bg("customMessageBg", t));
|
|
||||||
box.addChild(new Text(text, 0, 0));
|
|
||||||
return box;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Command to send status messages
|
|
||||||
pi.registerCommand("status", {
|
|
||||||
description: "Send a status message (usage: /status [warn|error] message)",
|
|
||||||
handler: async (args, _ctx) => {
|
|
||||||
const parts = args.trim().split(/\s+/);
|
|
||||||
let level = "info";
|
|
||||||
let content = args.trim();
|
|
||||||
|
|
||||||
// Check for level prefix
|
|
||||||
if (parts[0] === "warn" || parts[0] === "error") {
|
|
||||||
level = parts[0];
|
|
||||||
content = parts.slice(1).join(" ") || "Status update";
|
|
||||||
}
|
|
||||||
|
|
||||||
pi.sendMessage({
|
|
||||||
customType: "status-update",
|
|
||||||
content,
|
|
||||||
display: true,
|
|
||||||
details: { level, timestamp: Date.now() },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,426 +0,0 @@
|
||||||
/**
|
|
||||||
* Minimal Mode Example - Demonstrates a "minimal" tool display mode
|
|
||||||
*
|
|
||||||
* This extension overrides built-in tools to provide custom rendering:
|
|
||||||
* - Collapsed mode: Only shows the tool call (command/path), no output
|
|
||||||
* - Expanded mode: Shows full output like the built-in renderers
|
|
||||||
*
|
|
||||||
* This demonstrates how a "minimal mode" could work, where ctrl+o cycles through:
|
|
||||||
* - Standard: Shows truncated output (current default)
|
|
||||||
* - Expanded: Shows full output (current expanded)
|
|
||||||
* - Minimal: Shows only tool call, no output (this extension's collapsed mode)
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./minimal-mode.ts
|
|
||||||
*
|
|
||||||
* Then use ctrl+o to toggle between minimal (collapsed) and full (expanded) views.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import {
|
|
||||||
createBashTool,
|
|
||||||
createEditTool,
|
|
||||||
createFindTool,
|
|
||||||
createGrepTool,
|
|
||||||
createLsTool,
|
|
||||||
createReadTool,
|
|
||||||
createWriteTool,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Text } from "@mariozechner/pi-tui";
|
|
||||||
import { homedir } from "os";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shorten a path by replacing home directory with ~
|
|
||||||
*/
|
|
||||||
function shortenPath(path: string): string {
|
|
||||||
const home = homedir();
|
|
||||||
if (path.startsWith(home)) {
|
|
||||||
return `~${path.slice(home.length)}`;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache for built-in tools by cwd
|
|
||||||
const toolCache = new Map<string, ReturnType<typeof createBuiltInTools>>();
|
|
||||||
|
|
||||||
function createBuiltInTools(cwd: string) {
|
|
||||||
return {
|
|
||||||
read: createReadTool(cwd),
|
|
||||||
bash: createBashTool(cwd),
|
|
||||||
edit: createEditTool(cwd),
|
|
||||||
write: createWriteTool(cwd),
|
|
||||||
find: createFindTool(cwd),
|
|
||||||
grep: createGrepTool(cwd),
|
|
||||||
ls: createLsTool(cwd),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBuiltInTools(cwd: string) {
|
|
||||||
let tools = toolCache.get(cwd);
|
|
||||||
if (!tools) {
|
|
||||||
tools = createBuiltInTools(cwd);
|
|
||||||
toolCache.set(cwd, tools);
|
|
||||||
}
|
|
||||||
return tools;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// =========================================================================
|
|
||||||
// Read Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "read",
|
|
||||||
label: "read",
|
|
||||||
description:
|
|
||||||
"Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).read.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.read.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const path = shortenPath(args.path || "");
|
|
||||||
let pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
|
||||||
|
|
||||||
// Show line range if specified
|
|
||||||
if (args.offset !== undefined || args.limit !== undefined) {
|
|
||||||
const startLine = args.offset ?? 1;
|
|
||||||
const endLine = args.limit !== undefined ? startLine + args.limit - 1 : "";
|
|
||||||
pathDisplay += theme.fg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(`${theme.fg("toolTitle", theme.bold("read"))} ${pathDisplay}`, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
// Minimal mode: show nothing in collapsed state
|
|
||||||
if (!expanded) {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded mode: show full output
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = textContent.text.split("\n");
|
|
||||||
const output = lines.map((line) => theme.fg("toolOutput", line)).join("\n");
|
|
||||||
return new Text(`\n${output}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Bash Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "bash",
|
|
||||||
label: "bash",
|
|
||||||
description:
|
|
||||||
"Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first).",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).bash.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.bash.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const command = args.command || "...";
|
|
||||||
const timeout = args.timeout as number | undefined;
|
|
||||||
const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
|
|
||||||
|
|
||||||
return new Text(theme.fg("toolTitle", theme.bold(`$ ${command}`)) + timeoutSuffix, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
// Minimal mode: show nothing in collapsed state
|
|
||||||
if (!expanded) {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded mode: show full output
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = textContent.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => theme.fg("toolOutput", line))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
if (!output) {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(`\n${output}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Write Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "write",
|
|
||||||
label: "write",
|
|
||||||
description:
|
|
||||||
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).write.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.write.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const path = shortenPath(args.path || "");
|
|
||||||
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
|
||||||
const lineCount = args.content ? args.content.split("\n").length : 0;
|
|
||||||
const lineInfo = lineCount > 0 ? theme.fg("muted", ` (${lineCount} lines)`) : "";
|
|
||||||
|
|
||||||
return new Text(`${theme.fg("toolTitle", theme.bold("write"))} ${pathDisplay}${lineInfo}`, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
// Minimal mode: show nothing (file was written)
|
|
||||||
if (!expanded) {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded mode: show error if any
|
|
||||||
if (result.content.some((c) => c.type === "text" && c.text)) {
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (textContent?.type === "text" && textContent.text) {
|
|
||||||
return new Text(`\n${theme.fg("error", textContent.text)}`, 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Edit Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "edit",
|
|
||||||
label: "edit",
|
|
||||||
description:
|
|
||||||
"Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).edit.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.edit.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const path = shortenPath(args.path || "");
|
|
||||||
const pathDisplay = path ? theme.fg("accent", path) : theme.fg("toolOutput", "...");
|
|
||||||
|
|
||||||
return new Text(`${theme.fg("toolTitle", theme.bold("edit"))} ${pathDisplay}`, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
// Minimal mode: show nothing in collapsed state
|
|
||||||
if (!expanded) {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded mode: show diff or error
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For errors, show the error message
|
|
||||||
const text = textContent.text;
|
|
||||||
if (text.includes("Error") || text.includes("error")) {
|
|
||||||
return new Text(`\n${theme.fg("error", text)}`, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show the text (would be nice to show actual diff here)
|
|
||||||
return new Text(`\n${theme.fg("toolOutput", text)}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Find Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "find",
|
|
||||||
label: "find",
|
|
||||||
description:
|
|
||||||
"Find files by name pattern (glob). Searches recursively from the specified path. Output limited to 200 results.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).find.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.find.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const pattern = args.pattern || "";
|
|
||||||
const path = shortenPath(args.path || ".");
|
|
||||||
const limit = args.limit;
|
|
||||||
|
|
||||||
let text = `${theme.fg("toolTitle", theme.bold("find"))} ${theme.fg("accent", pattern)}`;
|
|
||||||
text += theme.fg("toolOutput", ` in ${path}`);
|
|
||||||
if (limit !== undefined) {
|
|
||||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
if (!expanded) {
|
|
||||||
// Minimal: just show count
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (textContent?.type === "text") {
|
|
||||||
const count = textContent.text.trim().split("\n").filter(Boolean).length;
|
|
||||||
if (count > 0) {
|
|
||||||
return new Text(theme.fg("muted", ` → ${count} files`), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded: show full results
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = textContent.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => theme.fg("toolOutput", line))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return new Text(`\n${output}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Grep Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "grep",
|
|
||||||
label: "grep",
|
|
||||||
description:
|
|
||||||
"Search file contents by regex pattern. Uses ripgrep for fast searching. Output limited to 200 matches.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).grep.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.grep.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const pattern = args.pattern || "";
|
|
||||||
const path = shortenPath(args.path || ".");
|
|
||||||
const glob = args.glob;
|
|
||||||
const limit = args.limit;
|
|
||||||
|
|
||||||
let text = `${theme.fg("toolTitle", theme.bold("grep"))} ${theme.fg("accent", `/${pattern}/`)}`;
|
|
||||||
text += theme.fg("toolOutput", ` in ${path}`);
|
|
||||||
if (glob) {
|
|
||||||
text += theme.fg("toolOutput", ` (${glob})`);
|
|
||||||
}
|
|
||||||
if (limit !== undefined) {
|
|
||||||
text += theme.fg("toolOutput", ` limit ${limit}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
if (!expanded) {
|
|
||||||
// Minimal: just show match count
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (textContent?.type === "text") {
|
|
||||||
const count = textContent.text.trim().split("\n").filter(Boolean).length;
|
|
||||||
if (count > 0) {
|
|
||||||
return new Text(theme.fg("muted", ` → ${count} matches`), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded: show full results
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = textContent.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => theme.fg("toolOutput", line))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return new Text(`\n${output}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// Ls Tool
|
|
||||||
// =========================================================================
|
|
||||||
pi.registerTool({
|
|
||||||
name: "ls",
|
|
||||||
label: "ls",
|
|
||||||
description:
|
|
||||||
"List directory contents with file sizes. Shows files and directories with their sizes. Output limited to 500 entries.",
|
|
||||||
parameters: getBuiltInTools(process.cwd()).ls.parameters,
|
|
||||||
|
|
||||||
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const tools = getBuiltInTools(ctx.cwd);
|
|
||||||
return tools.ls.execute(toolCallId, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const path = shortenPath(args.path || ".");
|
|
||||||
const limit = args.limit;
|
|
||||||
|
|
||||||
let text = `${theme.fg("toolTitle", theme.bold("ls"))} ${theme.fg("accent", path)}`;
|
|
||||||
if (limit !== undefined) {
|
|
||||||
text += theme.fg("toolOutput", ` (limit ${limit})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
if (!expanded) {
|
|
||||||
// Minimal: just show entry count
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (textContent?.type === "text") {
|
|
||||||
const count = textContent.text.trim().split("\n").filter(Boolean).length;
|
|
||||||
if (count > 0) {
|
|
||||||
return new Text(theme.fg("muted", ` → ${count} entries`), 0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded: show full listing
|
|
||||||
const textContent = result.content.find((c) => c.type === "text");
|
|
||||||
if (!textContent || textContent.type !== "text") {
|
|
||||||
return new Text("", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = textContent.text
|
|
||||||
.trim()
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => theme.fg("toolOutput", line))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return new Text(`\n${output}`, 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
/**
|
|
||||||
* Modal Editor - vim-like modal editing example
|
|
||||||
*
|
|
||||||
* Usage: pi --extension ./examples/extensions/modal-editor.ts
|
|
||||||
*
|
|
||||||
* - Escape: insert → normal mode (in normal mode, aborts agent)
|
|
||||||
* - i: normal → insert mode
|
|
||||||
* - hjkl: navigation in normal mode
|
|
||||||
* - ctrl+c, ctrl+d, etc. work in both modes
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
// Normal mode key mappings: key -> escape sequence (or null for mode switch)
|
|
||||||
const NORMAL_KEYS: Record<string, string | null> = {
|
|
||||||
h: "\x1b[D", // left
|
|
||||||
j: "\x1b[B", // down
|
|
||||||
k: "\x1b[A", // up
|
|
||||||
l: "\x1b[C", // right
|
|
||||||
"0": "\x01", // line start
|
|
||||||
$: "\x05", // line end
|
|
||||||
x: "\x1b[3~", // delete char
|
|
||||||
i: null, // insert mode
|
|
||||||
a: null, // append (insert + right)
|
|
||||||
};
|
|
||||||
|
|
||||||
class ModalEditor extends CustomEditor {
|
|
||||||
private mode: "normal" | "insert" = "insert";
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
// Escape toggles to normal mode, or passes through for app handling
|
|
||||||
if (matchesKey(data, "escape")) {
|
|
||||||
if (this.mode === "insert") {
|
|
||||||
this.mode = "normal";
|
|
||||||
} else {
|
|
||||||
super.handleInput(data); // abort agent, etc.
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert mode: pass everything through
|
|
||||||
if (this.mode === "insert") {
|
|
||||||
super.handleInput(data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normal mode: check mapped keys
|
|
||||||
if (data in NORMAL_KEYS) {
|
|
||||||
const seq = NORMAL_KEYS[data];
|
|
||||||
if (data === "i") {
|
|
||||||
this.mode = "insert";
|
|
||||||
} else if (data === "a") {
|
|
||||||
this.mode = "insert";
|
|
||||||
super.handleInput("\x1b[C"); // move right first
|
|
||||||
} else if (seq) {
|
|
||||||
super.handleInput(seq);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass control sequences (ctrl+c, etc.) to super, ignore printable chars
|
|
||||||
if (data.length === 1 && data.charCodeAt(0) >= 32) return;
|
|
||||||
super.handleInput(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const lines = super.render(width);
|
|
||||||
if (lines.length === 0) return lines;
|
|
||||||
|
|
||||||
// Add mode indicator to bottom border
|
|
||||||
const label = this.mode === "normal" ? " NORMAL " : " INSERT ";
|
|
||||||
const last = lines.length - 1;
|
|
||||||
if (visibleWidth(lines[last]!) >= label.length) {
|
|
||||||
lines[last] = truncateToWidth(lines[last]!, width - label.length, "") + label;
|
|
||||||
}
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
ctx.ui.setEditorComponent((tui, theme, kb) => new ModalEditor(tui, theme, kb));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
/**
|
|
||||||
* Model status extension - shows model changes in the status bar.
|
|
||||||
*
|
|
||||||
* Demonstrates the `model_select` hook which fires when the model changes
|
|
||||||
* via /model command, Ctrl+P cycling, or session restore.
|
|
||||||
*
|
|
||||||
* Usage: pi -e ./model-status.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("model_select", async (event, ctx) => {
|
|
||||||
const { model, previousModel, source } = event;
|
|
||||||
|
|
||||||
// Format model identifiers
|
|
||||||
const next = `${model.provider}/${model.id}`;
|
|
||||||
const prev = previousModel ? `${previousModel.provider}/${previousModel.id}` : "none";
|
|
||||||
|
|
||||||
// Show notification on change
|
|
||||||
if (source !== "restore") {
|
|
||||||
ctx.ui.notify(`Model: ${next}`, "info");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status bar with current model
|
|
||||||
ctx.ui.setStatus("model", `🤖 ${model.id}`);
|
|
||||||
|
|
||||||
// Log change details (visible in debug output)
|
|
||||||
console.log(`[model_select] ${prev} → ${next} (${source})`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
/**
|
|
||||||
* Pi Notify Extension
|
|
||||||
*
|
|
||||||
* Sends a native terminal notification when Pi agent is done and waiting for input.
|
|
||||||
* Supports multiple terminal protocols:
|
|
||||||
* - OSC 777: Ghostty, iTerm2, WezTerm, rxvt-unicode
|
|
||||||
* - OSC 99: Kitty
|
|
||||||
* - Windows toast: Windows Terminal (WSL)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
function windowsToastScript(title: string, body: string): string {
|
|
||||||
const type = "Windows.UI.Notifications";
|
|
||||||
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
|
|
||||||
const template = `[${type}.ToastTemplateType]::ToastText01`;
|
|
||||||
const toast = `[${type}.ToastNotification]::new($xml)`;
|
|
||||||
return [
|
|
||||||
`${mgr} > $null`,
|
|
||||||
`$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`,
|
|
||||||
`$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`,
|
|
||||||
`[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`,
|
|
||||||
].join("; ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyOSC777(title: string, body: string): void {
|
|
||||||
process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyOSC99(title: string, body: string): void {
|
|
||||||
// Kitty OSC 99: i=notification id, d=0 means not done yet, p=body for second part
|
|
||||||
process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
|
|
||||||
process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function notifyWindows(title: string, body: string): void {
|
|
||||||
const { execFile } = require("child_process");
|
|
||||||
execFile("powershell.exe", ["-NoProfile", "-Command", windowsToastScript(title, body)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function notify(title: string, body: string): void {
|
|
||||||
if (process.env.WT_SESSION) {
|
|
||||||
notifyWindows(title, body);
|
|
||||||
} else if (process.env.KITTY_WINDOW_ID) {
|
|
||||||
notifyOSC99(title, body);
|
|
||||||
} else {
|
|
||||||
notifyOSC777(title, body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("agent_end", async () => {
|
|
||||||
notify("Pi", "Ready for input");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,881 +0,0 @@
|
||||||
/**
|
|
||||||
* Overlay QA Tests - comprehensive overlay positioning and edge case tests
|
|
||||||
*
|
|
||||||
* Usage: pi --extension ./examples/extensions/overlay-qa-tests.ts
|
|
||||||
*
|
|
||||||
* Commands:
|
|
||||||
* /overlay-animation - Real-time animation demo (~30 FPS, proves DOOM-like rendering works)
|
|
||||||
* /overlay-anchors - Cycle through all 9 anchor positions
|
|
||||||
* /overlay-margins - Test margin and offset options
|
|
||||||
* /overlay-stack - Test stacked overlays
|
|
||||||
* /overlay-overflow - Test width overflow with streaming process output
|
|
||||||
* /overlay-edge - Test overlay positioned at terminal edge
|
|
||||||
* /overlay-percent - Test percentage-based positioning
|
|
||||||
* /overlay-maxheight - Test maxHeight truncation
|
|
||||||
* /overlay-sidepanel - Responsive sidepanel (hides when terminal < 100 cols)
|
|
||||||
* /overlay-toggle - Toggle visibility demo (demonstrates OverlayHandle.setHidden)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
||||||
import type { OverlayAnchor, OverlayHandle, OverlayOptions, TUI } from "@mariozechner/pi-tui";
|
|
||||||
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
import { spawn } from "child_process";
|
|
||||||
|
|
||||||
// Global handle for toggle demo (in real code, use a more elegant pattern)
|
|
||||||
let globalToggleHandle: OverlayHandle | null = null;
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Animation demo - proves overlays can handle real-time updates (like pi-doom would need)
|
|
||||||
pi.registerCommand("overlay-animation", {
|
|
||||||
description: "Test real-time animation in overlay (~30 FPS)",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new AnimationDemoComponent(tui, theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50, maxHeight: 20 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test all 9 anchor positions
|
|
||||||
pi.registerCommand("overlay-anchors", {
|
|
||||||
description: "Cycle through all anchor positions",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
const anchors: OverlayAnchor[] = [
|
|
||||||
"top-left",
|
|
||||||
"top-center",
|
|
||||||
"top-right",
|
|
||||||
"left-center",
|
|
||||||
"center",
|
|
||||||
"right-center",
|
|
||||||
"bottom-left",
|
|
||||||
"bottom-center",
|
|
||||||
"bottom-right",
|
|
||||||
];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
while (true) {
|
|
||||||
const result = await ctx.ui.custom<"next" | "confirm" | "cancel">(
|
|
||||||
(_tui, theme, _kb, done) => new AnchorTestComponent(theme, anchors[index]!, done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: anchors[index], width: 40 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "next") {
|
|
||||||
index = (index + 1) % anchors.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (result === "confirm") {
|
|
||||||
ctx.ui.notify(`Selected: ${anchors[index]}`, "info");
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test margins and offsets
|
|
||||||
pi.registerCommand("overlay-margins", {
|
|
||||||
description: "Test margin and offset options",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
const configs: { name: string; options: OverlayOptions }[] = [
|
|
||||||
{ name: "No margin (top-left)", options: { anchor: "top-left", width: 35 } },
|
|
||||||
{ name: "Margin: 3 all sides", options: { anchor: "top-left", width: 35, margin: 3 } },
|
|
||||||
{
|
|
||||||
name: "Margin: top=5, left=10",
|
|
||||||
options: { anchor: "top-left", width: 35, margin: { top: 5, left: 10 } },
|
|
||||||
},
|
|
||||||
{ name: "Center + offset (10, -3)", options: { anchor: "center", width: 35, offsetX: 10, offsetY: -3 } },
|
|
||||||
{ name: "Bottom-right, margin: 2", options: { anchor: "bottom-right", width: 35, margin: 2 } },
|
|
||||||
];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
while (true) {
|
|
||||||
const result = await ctx.ui.custom<"next" | "close">(
|
|
||||||
(_tui, theme, _kb, done) => new MarginTestComponent(theme, configs[index]!, done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: configs[index]!.options,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "next") {
|
|
||||||
index = (index + 1) % configs.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test stacked overlays
|
|
||||||
pi.registerCommand("overlay-stack", {
|
|
||||||
description: "Test stacked overlays",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
// Three large overlays that overlap in the center area
|
|
||||||
// Each offset slightly so you can see the stacking
|
|
||||||
|
|
||||||
ctx.ui.notify("Showing overlay 1 (back)...", "info");
|
|
||||||
const p1 = ctx.ui.custom<string>(
|
|
||||||
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 1, "back (red border)", done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50, offsetX: -8, offsetY: -4, maxHeight: 15 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await sleep(400);
|
|
||||||
|
|
||||||
ctx.ui.notify("Showing overlay 2 (middle)...", "info");
|
|
||||||
const p2 = ctx.ui.custom<string>(
|
|
||||||
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 2, "middle (green border)", done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50, offsetX: 0, offsetY: 0, maxHeight: 15 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await sleep(400);
|
|
||||||
|
|
||||||
ctx.ui.notify("Showing overlay 3 (front)...", "info");
|
|
||||||
const p3 = ctx.ui.custom<string>(
|
|
||||||
(_tui, theme, _kb, done) => new StackOverlayComponent(theme, 3, "front (blue border)", done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50, offsetX: 8, offsetY: 4, maxHeight: 15 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Wait for all to close
|
|
||||||
const results = await Promise.all([p1, p2, p3]);
|
|
||||||
ctx.ui.notify(`Closed in order: ${results.join(", ")}`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test width overflow scenarios (original crash case) - streams real process output
|
|
||||||
pi.registerCommand("overlay-overflow", {
|
|
||||||
description: "Test width overflow with streaming process output",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new StreamingOverflowComponent(tui, theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 90, maxHeight: 20 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test overlay at terminal edge
|
|
||||||
pi.registerCommand("overlay-edge", {
|
|
||||||
description: "Test overlay positioned at terminal edge",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new EdgeTestComponent(theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "right-center", width: 40, margin: { right: 0 } },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test percentage-based positioning
|
|
||||||
pi.registerCommand("overlay-percent", {
|
|
||||||
description: "Test percentage-based positioning",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
const configs = [
|
|
||||||
{ name: "rowPercent: 0 (top)", row: 0, col: 50 },
|
|
||||||
{ name: "rowPercent: 50 (middle)", row: 50, col: 50 },
|
|
||||||
{ name: "rowPercent: 100 (bottom)", row: 100, col: 50 },
|
|
||||||
{ name: "colPercent: 0 (left)", row: 50, col: 0 },
|
|
||||||
{ name: "colPercent: 100 (right)", row: 50, col: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
while (true) {
|
|
||||||
const config = configs[index]!;
|
|
||||||
const result = await ctx.ui.custom<"next" | "close">(
|
|
||||||
(_tui, theme, _kb, done) => new PercentTestComponent(theme, config, done),
|
|
||||||
{
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: {
|
|
||||||
width: 30,
|
|
||||||
row: `${config.row}%`,
|
|
||||||
col: `${config.col}%`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === "next") {
|
|
||||||
index = (index + 1) % configs.length;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test maxHeight
|
|
||||||
pi.registerCommand("overlay-maxheight", {
|
|
||||||
description: "Test maxHeight truncation",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((_tui, theme, _kb, done) => new MaxHeightTestComponent(theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50, maxHeight: 10 },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test responsive sidepanel - only shows when terminal is wide enough
|
|
||||||
pi.registerCommand("overlay-sidepanel", {
|
|
||||||
description: "Test responsive sidepanel (hides when terminal < 100 cols)",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new SidepanelComponent(tui, theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: {
|
|
||||||
anchor: "right-center",
|
|
||||||
width: "25%",
|
|
||||||
minWidth: 30,
|
|
||||||
margin: { right: 1 },
|
|
||||||
// Only show when terminal is wide enough
|
|
||||||
visible: (termWidth) => termWidth >= 100,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Test toggle overlay - demonstrates OverlayHandle.setHidden() via onHandle callback
|
|
||||||
pi.registerCommand("overlay-toggle", {
|
|
||||||
description: "Test overlay toggle (press 't' to toggle visibility)",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
await ctx.ui.custom<void>((tui, theme, _kb, done) => new ToggleDemoComponent(tui, theme, done), {
|
|
||||||
overlay: true,
|
|
||||||
overlayOptions: { anchor: "center", width: 50 },
|
|
||||||
// onHandle callback provides access to the OverlayHandle for visibility control
|
|
||||||
onHandle: (handle) => {
|
|
||||||
// Store handle globally so component can access it
|
|
||||||
// (In real code, you'd use a more elegant pattern like a store or event emitter)
|
|
||||||
globalToggleHandle = handle;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
globalToggleHandle = null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base overlay component with common rendering
|
|
||||||
abstract class BaseOverlay {
|
|
||||||
constructor(protected theme: Theme) {}
|
|
||||||
|
|
||||||
protected box(lines: string[], width: number, title?: string): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
const innerW = Math.max(1, width - 2);
|
|
||||||
const result: string[] = [];
|
|
||||||
|
|
||||||
const titleStr = title ? truncateToWidth(` ${title} `, innerW) : "";
|
|
||||||
const titleW = visibleWidth(titleStr);
|
|
||||||
const topLine = "─".repeat(Math.floor((innerW - titleW) / 2));
|
|
||||||
const topLine2 = "─".repeat(Math.max(0, innerW - titleW - topLine.length));
|
|
||||||
result.push(th.fg("border", `╭${topLine}`) + th.fg("accent", titleStr) + th.fg("border", `${topLine2}╮`));
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
result.push(th.fg("border", "│") + truncateToWidth(line, innerW, "...", true) + th.fg("border", "│"));
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(): void {}
|
|
||||||
dispose(): void {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anchor position test
|
|
||||||
class AnchorTestComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private anchor: OverlayAnchor,
|
|
||||||
private done: (result: "next" | "confirm" | "cancel") => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done("cancel");
|
|
||||||
} else if (matchesKey(data, "return")) {
|
|
||||||
this.done("confirm");
|
|
||||||
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
|
|
||||||
this.done("next");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
return this.box(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
` Current: ${th.fg("accent", this.anchor)}`,
|
|
||||||
"",
|
|
||||||
` ${th.fg("dim", "Space/→ = next anchor")}`,
|
|
||||||
` ${th.fg("dim", "Enter = confirm")}`,
|
|
||||||
` ${th.fg("dim", "Esc = cancel")}`,
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
"Anchor Test",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Margin/offset test
|
|
||||||
class MarginTestComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private config: { name: string; options: OverlayOptions },
|
|
||||||
private done: (result: "next" | "close") => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done("close");
|
|
||||||
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
|
|
||||||
this.done("next");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
return this.box(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
` ${th.fg("accent", this.config.name)}`,
|
|
||||||
"",
|
|
||||||
` ${th.fg("dim", "Space/→ = next config")}`,
|
|
||||||
` ${th.fg("dim", "Esc = close")}`,
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
"Margin Test",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stacked overlay test
|
|
||||||
class StackOverlayComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private num: number,
|
|
||||||
private position: string,
|
|
||||||
private done: (result: string) => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "return")) {
|
|
||||||
this.done(`Overlay ${this.num}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
// Use different colors for each overlay to show stacking
|
|
||||||
const colors = ["error", "success", "accent"] as const;
|
|
||||||
const color = colors[(this.num - 1) % colors.length]!;
|
|
||||||
const innerW = Math.max(1, width - 2);
|
|
||||||
const border = (char: string) => th.fg(color, char);
|
|
||||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(border(`╭${"─".repeat(innerW)}╮`));
|
|
||||||
lines.push(border("│") + padLine(` Overlay ${th.fg("accent", `#${this.num}`)}`) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(` Layer: ${th.fg(color, this.position)}`) + border("│"));
|
|
||||||
lines.push(border("│") + padLine("") + border("│"));
|
|
||||||
// Add extra lines to make it taller
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
lines.push(border("│") + padLine(` ${"░".repeat(innerW - 2)} `) + border("│"));
|
|
||||||
}
|
|
||||||
lines.push(border("│") + padLine("") + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " Press Enter/Esc to close")) + border("│"));
|
|
||||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Streaming overflow test - spawns real process with colored output (original crash scenario)
|
|
||||||
class StreamingOverflowComponent extends BaseOverlay {
|
|
||||||
private lines: string[] = [];
|
|
||||||
private proc: ReturnType<typeof spawn> | null = null;
|
|
||||||
private scrollOffset = 0;
|
|
||||||
private maxVisibleLines = 15;
|
|
||||||
private finished = false;
|
|
||||||
private disposed = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private tui: TUI,
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
this.startProcess();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startProcess(): void {
|
|
||||||
// Run a command that produces many lines with ANSI colors
|
|
||||||
// Using find with -ls produces file listings, or use ls --color
|
|
||||||
this.proc = spawn("bash", [
|
|
||||||
"-c",
|
|
||||||
`
|
|
||||||
echo "Starting streaming overflow test (30+ seconds)..."
|
|
||||||
echo "This simulates subagent output with colors, hyperlinks, and long paths"
|
|
||||||
echo ""
|
|
||||||
for i in $(seq 1 100); do
|
|
||||||
# Simulate long file paths with OSC 8 hyperlinks (clickable) - tests width overflow
|
|
||||||
DIR="/Users/nicobailon/Documents/development/pi-mono/packages/coding-agent/src/modes/interactive"
|
|
||||||
FILE="\${DIR}/components/very-long-component-name-that-exceeds-width-\${i}.ts"
|
|
||||||
echo -e "\\033]8;;file://\${FILE}\\007▶ read: \${FILE}\\033]8;;\\007"
|
|
||||||
|
|
||||||
# Add some colored status messages with long text
|
|
||||||
if [ $((i % 5)) -eq 0 ]; then
|
|
||||||
echo -e " \\033[32m✓ Successfully processed \${i} files in /Users/nicobailon/Documents/development/pi-mono\\033[0m"
|
|
||||||
fi
|
|
||||||
if [ $((i % 7)) -eq 0 ]; then
|
|
||||||
echo -e " \\033[33m⚠ Warning: potential issue detected at line \${i} in very-long-component-name-that-exceeds-width.ts\\033[0m"
|
|
||||||
fi
|
|
||||||
if [ $((i % 11)) -eq 0 ]; then
|
|
||||||
echo -e " \\033[31m✗ Error: file not found /some/really/long/path/that/definitely/exceeds/the/overlay/width/limit/file-\${i}.ts\\033[0m"
|
|
||||||
fi
|
|
||||||
sleep 0.3
|
|
||||||
done
|
|
||||||
echo ""
|
|
||||||
echo -e "\\033[32m✓ Complete - 100 files processed in 30 seconds\\033[0m"
|
|
||||||
echo "Press Esc to close"
|
|
||||||
`,
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.proc.stdout?.on("data", (data: Buffer) => {
|
|
||||||
if (this.disposed) return; // Guard against callbacks after dispose
|
|
||||||
const text = data.toString();
|
|
||||||
const newLines = text.split("\n");
|
|
||||||
for (const line of newLines) {
|
|
||||||
if (line) this.lines.push(line);
|
|
||||||
}
|
|
||||||
// Auto-scroll to bottom
|
|
||||||
this.scrollOffset = Math.max(0, this.lines.length - this.maxVisibleLines);
|
|
||||||
this.tui.requestRender();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.proc.stderr?.on("data", (data: Buffer) => {
|
|
||||||
if (this.disposed) return; // Guard against callbacks after dispose
|
|
||||||
this.lines.push(this.theme.fg("error", data.toString().trim()));
|
|
||||||
this.tui.requestRender();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.proc.on("close", () => {
|
|
||||||
if (this.disposed) return; // Guard against callbacks after dispose
|
|
||||||
this.finished = true;
|
|
||||||
this.tui.requestRender();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.proc?.kill();
|
|
||||||
this.done();
|
|
||||||
} else if (matchesKey(data, "up")) {
|
|
||||||
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
||||||
this.tui.requestRender(); // Trigger re-render after scroll
|
|
||||||
} else if (matchesKey(data, "down")) {
|
|
||||||
this.scrollOffset = Math.min(Math.max(0, this.lines.length - this.maxVisibleLines), this.scrollOffset + 1);
|
|
||||||
this.tui.requestRender(); // Trigger re-render after scroll
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
const innerW = Math.max(1, width - 2);
|
|
||||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
||||||
const border = (c: string) => th.fg("border", c);
|
|
||||||
|
|
||||||
const result: string[] = [];
|
|
||||||
const title = truncateToWidth(` Streaming Output (${this.lines.length} lines) `, innerW);
|
|
||||||
const titlePad = Math.max(0, innerW - visibleWidth(title));
|
|
||||||
result.push(border("╭") + th.fg("accent", title) + border(`${"─".repeat(titlePad)}╮`));
|
|
||||||
|
|
||||||
// Scroll indicators
|
|
||||||
const canScrollUp = this.scrollOffset > 0;
|
|
||||||
const canScrollDown = this.scrollOffset < this.lines.length - this.maxVisibleLines;
|
|
||||||
const scrollInfo = `↑${this.scrollOffset} | ↓${Math.max(0, this.lines.length - this.maxVisibleLines - this.scrollOffset)}`;
|
|
||||||
|
|
||||||
result.push(
|
|
||||||
border("│") + padLine(canScrollUp || canScrollDown ? th.fg("dim", ` ${scrollInfo}`) : "") + border("│"),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Visible lines - truncate long lines to fit within border
|
|
||||||
const visibleLines = this.lines.slice(this.scrollOffset, this.scrollOffset + this.maxVisibleLines);
|
|
||||||
for (const line of visibleLines) {
|
|
||||||
result.push(border("│") + padLine(` ${line}`) + border("│"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pad to maxVisibleLines
|
|
||||||
for (let i = visibleLines.length; i < this.maxVisibleLines; i++) {
|
|
||||||
result.push(border("│") + padLine("") + border("│"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = this.finished ? th.fg("success", "✓ Done") : th.fg("warning", "● Running");
|
|
||||||
result.push(border("│") + padLine(` ${status} ${th.fg("dim", "| ↑↓ scroll | Esc close")}`) + border("│"));
|
|
||||||
result.push(border(`╰${"─".repeat(innerW)}╯`));
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.disposed = true;
|
|
||||||
this.proc?.kill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edge position test
|
|
||||||
class EdgeTestComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
return this.box(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
" This overlay is at the",
|
|
||||||
" right edge of terminal.",
|
|
||||||
"",
|
|
||||||
` ${th.fg("dim", "Verify right border")}`,
|
|
||||||
` ${th.fg("dim", "aligns with edge.")}`,
|
|
||||||
"",
|
|
||||||
` ${th.fg("dim", "Press Esc to close")}`,
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
"Edge Test",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Percentage positioning test
|
|
||||||
class PercentTestComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private config: { name: string; row: number; col: number },
|
|
||||||
private done: (result: "next" | "close") => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done("close");
|
|
||||||
} else if (matchesKey(data, "space") || matchesKey(data, "right")) {
|
|
||||||
this.done("next");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
return this.box(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
` ${th.fg("accent", this.config.name)}`,
|
|
||||||
"",
|
|
||||||
` ${th.fg("dim", "Space/→ = next")}`,
|
|
||||||
` ${th.fg("dim", "Esc = close")}`,
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
"Percent Test",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxHeight test - renders 20 lines, truncated to 10 by maxHeight
|
|
||||||
class MaxHeightTestComponent extends BaseOverlay {
|
|
||||||
constructor(
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
// Intentionally render 21 lines - maxHeight: 10 will truncate to first 10
|
|
||||||
// You should see header + lines 1-6, with bottom border cut off
|
|
||||||
const contentLines: string[] = [
|
|
||||||
th.fg("warning", " ⚠ Rendering 21 lines, maxHeight: 10"),
|
|
||||||
th.fg("dim", " Lines 11-21 truncated (no bottom border)"),
|
|
||||||
"",
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = 1; i <= 14; i++) {
|
|
||||||
contentLines.push(` Line ${i} of 14`);
|
|
||||||
}
|
|
||||||
|
|
||||||
contentLines.push("", th.fg("dim", " Press Esc to close"));
|
|
||||||
|
|
||||||
return this.box(contentLines, width, "MaxHeight Test");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Responsive sidepanel - demonstrates percentage width and visibility callback
|
|
||||||
class SidepanelComponent extends BaseOverlay {
|
|
||||||
private items = ["Dashboard", "Messages", "Settings", "Help", "About"];
|
|
||||||
private selectedIndex = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private tui: TUI,
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done();
|
|
||||||
} else if (matchesKey(data, "up")) {
|
|
||||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
||||||
this.tui.requestRender();
|
|
||||||
} else if (matchesKey(data, "down")) {
|
|
||||||
this.selectedIndex = Math.min(this.items.length - 1, this.selectedIndex + 1);
|
|
||||||
this.tui.requestRender();
|
|
||||||
} else if (matchesKey(data, "return")) {
|
|
||||||
// Could trigger an action here
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
const innerW = Math.max(1, width - 2);
|
|
||||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
||||||
const border = (c: string) => th.fg("border", c);
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Header
|
|
||||||
lines.push(border(`╭${"─".repeat(innerW)}╮`));
|
|
||||||
lines.push(border("│") + padLine(th.fg("accent", " Responsive Sidepanel")) + border("│"));
|
|
||||||
lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));
|
|
||||||
|
|
||||||
// Menu items
|
|
||||||
for (let i = 0; i < this.items.length; i++) {
|
|
||||||
const item = this.items[i]!;
|
|
||||||
const isSelected = i === this.selectedIndex;
|
|
||||||
const prefix = isSelected ? th.fg("accent", "→ ") : " ";
|
|
||||||
const text = isSelected ? th.fg("accent", item) : item;
|
|
||||||
lines.push(border("│") + padLine(`${prefix}${text}`) + border("│"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Footer with responsive behavior info
|
|
||||||
lines.push(border("├") + border("─".repeat(innerW)) + border("┤"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("warning", " ⚠ Resize terminal < 100 cols")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("warning", " to see panel auto-hide")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " Uses visible: (w) => w >= 100")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " ↑↓ navigate | Esc close")) + border("│"));
|
|
||||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Animation demo - proves overlays can handle real-time updates like pi-doom
|
|
||||||
class AnimationDemoComponent extends BaseOverlay {
|
|
||||||
private frame = 0;
|
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private fps = 0;
|
|
||||||
private lastFpsUpdate = Date.now();
|
|
||||||
private framesSinceLastFps = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private tui: TUI,
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
this.startAnimation();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAnimation(): void {
|
|
||||||
// Run at ~30 FPS (same as DOOM target)
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
this.frame++;
|
|
||||||
this.framesSinceLastFps++;
|
|
||||||
|
|
||||||
// Update FPS counter every second
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastFpsUpdate >= 1000) {
|
|
||||||
this.fps = this.framesSinceLastFps;
|
|
||||||
this.framesSinceLastFps = 0;
|
|
||||||
this.lastFpsUpdate = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.tui.requestRender();
|
|
||||||
}, 1000 / 30);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.dispose();
|
|
||||||
this.done();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
const innerW = Math.max(1, width - 2);
|
|
||||||
const padLine = (s: string) => truncateToWidth(s, innerW, "...", true);
|
|
||||||
const border = (c: string) => th.fg("border", c);
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
lines.push(border(`╭${"─".repeat(innerW)}╮`));
|
|
||||||
lines.push(border("│") + padLine(th.fg("accent", " Animation Demo (~30 FPS)")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(``) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(` Frame: ${th.fg("accent", String(this.frame))}`) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(` FPS: ${th.fg("success", String(this.fps))}`) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(``) + border("│"));
|
|
||||||
|
|
||||||
// Animated content - bouncing bar
|
|
||||||
const barWidth = Math.max(12, innerW - 4); // Ensure enough space for bar
|
|
||||||
const pos = Math.max(0, Math.floor(((Math.sin(this.frame / 10) + 1) * (barWidth - 10)) / 2));
|
|
||||||
const bar = " ".repeat(pos) + th.fg("accent", "██████████") + " ".repeat(Math.max(0, barWidth - 10 - pos));
|
|
||||||
lines.push(border("│") + padLine(` ${bar}`) + border("│"));
|
|
||||||
|
|
||||||
// Spinning character
|
|
||||||
const spinChars = ["◐", "◓", "◑", "◒"];
|
|
||||||
const spin = spinChars[this.frame % spinChars.length];
|
|
||||||
lines.push(border("│") + padLine(` Spinner: ${th.fg("warning", spin!)}`) + border("│"));
|
|
||||||
|
|
||||||
// Color cycling
|
|
||||||
const hue = (this.frame * 3) % 360;
|
|
||||||
const rgb = hslToRgb(hue / 360, 0.8, 0.5);
|
|
||||||
const colorBlock = `\x1b[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m${" ".repeat(10)}\x1b[0m`;
|
|
||||||
lines.push(border("│") + padLine(` Color: ${colorBlock}`) + border("│"));
|
|
||||||
|
|
||||||
lines.push(border("│") + padLine(``) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " This proves overlays can handle")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " real-time game-like rendering.")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " (pi-doom uses same approach)")) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(``) + border("│"));
|
|
||||||
lines.push(border("│") + padLine(th.fg("dim", " Press Esc to close")) + border("│"));
|
|
||||||
lines.push(border(`╰${"─".repeat(innerW)}╯`));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HSL to RGB helper for color cycling animation
|
|
||||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
|
||||||
let r: number, g: number, b: number;
|
|
||||||
if (s === 0) {
|
|
||||||
r = g = b = l;
|
|
||||||
} else {
|
|
||||||
const hue2rgb = (p: number, q: number, t: number) => {
|
|
||||||
if (t < 0) t += 1;
|
|
||||||
if (t > 1) t -= 1;
|
|
||||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
||||||
if (t < 1 / 2) return q;
|
|
||||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
||||||
return p;
|
|
||||||
};
|
|
||||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
||||||
const p = 2 * l - q;
|
|
||||||
r = hue2rgb(p, q, h + 1 / 3);
|
|
||||||
g = hue2rgb(p, q, h);
|
|
||||||
b = hue2rgb(p, q, h - 1 / 3);
|
|
||||||
}
|
|
||||||
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle demo - demonstrates OverlayHandle.setHidden() via onHandle callback
|
|
||||||
class ToggleDemoComponent extends BaseOverlay {
|
|
||||||
private toggleCount = 0;
|
|
||||||
private isToggling = false;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private tui: TUI,
|
|
||||||
theme: Theme,
|
|
||||||
private done: () => void,
|
|
||||||
) {
|
|
||||||
super(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
||||||
this.done();
|
|
||||||
} else if (matchesKey(data, "t") && globalToggleHandle && !this.isToggling) {
|
|
||||||
// Demonstrate toggle by hiding for 1 second then showing again
|
|
||||||
// (In real usage, a global keybinding would control visibility)
|
|
||||||
this.isToggling = true;
|
|
||||||
this.toggleCount++;
|
|
||||||
globalToggleHandle.setHidden(true);
|
|
||||||
|
|
||||||
// Auto-restore after 1 second to demonstrate the API
|
|
||||||
setTimeout(() => {
|
|
||||||
if (globalToggleHandle) {
|
|
||||||
globalToggleHandle.setHidden(false);
|
|
||||||
this.isToggling = false;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
const th = this.theme;
|
|
||||||
return this.box(
|
|
||||||
[
|
|
||||||
"",
|
|
||||||
th.fg("accent", " Toggle Demo"),
|
|
||||||
"",
|
|
||||||
" This overlay demonstrates the",
|
|
||||||
" onHandle callback API.",
|
|
||||||
"",
|
|
||||||
` Toggle count: ${th.fg("accent", String(this.toggleCount))}`,
|
|
||||||
"",
|
|
||||||
th.fg("dim", " Press 't' to hide for 1 second"),
|
|
||||||
th.fg("dim", " (demonstrates setHidden API)"),
|
|
||||||
"",
|
|
||||||
th.fg("dim", " In real usage, a global keybinding"),
|
|
||||||
th.fg("dim", " would toggle visibility externally."),
|
|
||||||
"",
|
|
||||||
th.fg("dim", " Press Esc to close"),
|
|
||||||
"",
|
|
||||||
],
|
|
||||||
width,
|
|
||||||
"Toggle Demo",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
/**
|
|
||||||
* Overlay Test - validates overlay compositing with inline text inputs
|
|
||||||
*
|
|
||||||
* Usage: pi --extension ./examples/extensions/overlay-test.ts
|
|
||||||
*
|
|
||||||
* Run /overlay-test to show a floating overlay with:
|
|
||||||
* - Inline text inputs within menu items
|
|
||||||
* - Edge case tests (wide chars, styled text, emoji)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { CURSOR_MARKER, type Focusable, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("overlay-test", {
|
|
||||||
description: "Test overlay rendering with edge cases",
|
|
||||||
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
||||||
const result = await ctx.ui.custom<{ action: string; query?: string } | undefined>(
|
|
||||||
(_tui, theme, _keybindings, done) => new OverlayTestComponent(theme, done),
|
|
||||||
{ overlay: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
const msg = result.query ? `${result.action}: "${result.query}"` : result.action;
|
|
||||||
ctx.ui.notify(msg, "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class OverlayTestComponent implements Focusable {
|
|
||||||
readonly width = 70;
|
|
||||||
|
|
||||||
/** Focusable interface - set by TUI when focus changes */
|
|
||||||
focused = false;
|
|
||||||
|
|
||||||
private selected = 0;
|
|
||||||
private items = [
|
|
||||||
{ label: "Search", hasInput: true, text: "", cursor: 0 },
|
|
||||||
{ label: "Run", hasInput: true, text: "", cursor: 0 },
|
|
||||||
{ label: "Settings", hasInput: false, text: "", cursor: 0 },
|
|
||||||
{ label: "Cancel", hasInput: false, text: "", cursor: 0 },
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private theme: Theme,
|
|
||||||
private done: (result: { action: string; query?: string } | undefined) => void,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
if (matchesKey(data, "escape")) {
|
|
||||||
this.done(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = this.items[this.selected]!;
|
|
||||||
|
|
||||||
if (matchesKey(data, "return")) {
|
|
||||||
this.done({ action: current.label, query: current.hasInput ? current.text : undefined });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, "up")) {
|
|
||||||
this.selected = Math.max(0, this.selected - 1);
|
|
||||||
} else if (matchesKey(data, "down")) {
|
|
||||||
this.selected = Math.min(this.items.length - 1, this.selected + 1);
|
|
||||||
} else if (current.hasInput) {
|
|
||||||
if (matchesKey(data, "backspace")) {
|
|
||||||
if (current.cursor > 0) {
|
|
||||||
current.text = current.text.slice(0, current.cursor - 1) + current.text.slice(current.cursor);
|
|
||||||
current.cursor--;
|
|
||||||
}
|
|
||||||
} else if (matchesKey(data, "left")) {
|
|
||||||
current.cursor = Math.max(0, current.cursor - 1);
|
|
||||||
} else if (matchesKey(data, "right")) {
|
|
||||||
current.cursor = Math.min(current.text.length, current.cursor + 1);
|
|
||||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
|
||||||
current.text = current.text.slice(0, current.cursor) + data + current.text.slice(current.cursor);
|
|
||||||
current.cursor++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(_width: number): string[] {
|
|
||||||
const w = this.width;
|
|
||||||
const th = this.theme;
|
|
||||||
const innerW = w - 2;
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
const pad = (s: string, len: number) => {
|
|
||||||
const vis = visibleWidth(s);
|
|
||||||
return s + " ".repeat(Math.max(0, len - vis));
|
|
||||||
};
|
|
||||||
|
|
||||||
const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
|
|
||||||
|
|
||||||
lines.push(th.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
||||||
lines.push(row(` ${th.fg("accent", "🧪 Overlay Test")}`));
|
|
||||||
lines.push(row(""));
|
|
||||||
|
|
||||||
// Edge cases - full width lines to test compositing at boundaries
|
|
||||||
lines.push(row(` ${th.fg("dim", "─── Edge Cases (borders should align) ───")}`));
|
|
||||||
lines.push(row(` Wide: ${th.fg("warning", "中文日本語한글テスト漢字繁體简体ひらがなカタカナ가나다라마바")}`));
|
|
||||||
lines.push(
|
|
||||||
row(
|
|
||||||
` Styled: ${th.fg("error", "RED")} ${th.fg("success", "GREEN")} ${th.fg("warning", "YELLOW")} ${th.fg("accent", "ACCENT")} ${th.fg("dim", "DIM")} ${th.fg("error", "more")} ${th.fg("success", "colors")}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
lines.push(row(" Emoji: 👨👩👧👦 🇯🇵 🚀 💻 🎉 🔥 😀 🎯 🌟 💡 🎨 🔧 📦 🏆 🌈 🎪 🎭 🎬 🎮 🎲"));
|
|
||||||
lines.push(row(""));
|
|
||||||
|
|
||||||
// Menu with inline inputs
|
|
||||||
lines.push(row(` ${th.fg("dim", "─── Actions ───")}`));
|
|
||||||
|
|
||||||
for (let i = 0; i < this.items.length; i++) {
|
|
||||||
const item = this.items[i]!;
|
|
||||||
const isSelected = i === this.selected;
|
|
||||||
const prefix = isSelected ? " ▶ " : " ";
|
|
||||||
|
|
||||||
let content: string;
|
|
||||||
if (item.hasInput) {
|
|
||||||
const label = isSelected ? th.fg("accent", `${item.label}:`) : th.fg("text", `${item.label}:`);
|
|
||||||
|
|
||||||
let inputDisplay = item.text;
|
|
||||||
if (isSelected) {
|
|
||||||
const before = inputDisplay.slice(0, item.cursor);
|
|
||||||
const cursorChar = item.cursor < inputDisplay.length ? inputDisplay[item.cursor] : " ";
|
|
||||||
const after = inputDisplay.slice(item.cursor + 1);
|
|
||||||
// Emit hardware cursor marker for IME support when focused
|
|
||||||
const marker = this.focused ? CURSOR_MARKER : "";
|
|
||||||
inputDisplay = `${before}${marker}\x1b[7m${cursorChar}\x1b[27m${after}`;
|
|
||||||
}
|
|
||||||
content = `${prefix + label} ${inputDisplay}`;
|
|
||||||
} else {
|
|
||||||
content = prefix + (isSelected ? th.fg("accent", item.label) : th.fg("text", item.label));
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(row(content));
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(row(""));
|
|
||||||
lines.push(row(` ${th.fg("dim", "↑↓ navigate • type to input • Enter select • Esc cancel")}`));
|
|
||||||
lines.push(th.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(): void {}
|
|
||||||
dispose(): void {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
/**
|
|
||||||
* Permission Gate Extension
|
|
||||||
*
|
|
||||||
* Prompts for confirmation before running potentially dangerous bash commands.
|
|
||||||
* Patterns checked: rm -rf, sudo, chmod/chown 777
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
|
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
|
||||||
if (event.toolName !== "bash") return undefined;
|
|
||||||
|
|
||||||
const command = event.input.command as string;
|
|
||||||
const isDangerous = dangerousPatterns.some((p) => p.test(command));
|
|
||||||
|
|
||||||
if (isDangerous) {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
// In non-interactive mode, block by default
|
|
||||||
return { block: true, reason: "Dangerous command blocked (no UI for confirmation)" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select(`⚠️ Dangerous command:\n\n ${command}\n\nAllow?`, ["Yes", "No"]);
|
|
||||||
|
|
||||||
if (choice !== "Yes") {
|
|
||||||
return { block: true, reason: "Blocked by user" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
/**
|
|
||||||
* Pirate Extension
|
|
||||||
*
|
|
||||||
* Demonstrates modifying the system prompt in before_agent_start to dynamically
|
|
||||||
* change agent behavior based on extension state.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* 1. Copy this file to ~/.pi/agent/extensions/ or your project's .pi/extensions/
|
|
||||||
* 2. Use /pirate to toggle pirate mode
|
|
||||||
* 3. When enabled, the agent will respond like a pirate
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function pirateExtension(pi: ExtensionAPI) {
|
|
||||||
let pirateMode = false;
|
|
||||||
|
|
||||||
// Register /pirate command to toggle pirate mode
|
|
||||||
pi.registerCommand("pirate", {
|
|
||||||
description: "Toggle pirate mode (agent speaks like a pirate)",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
pirateMode = !pirateMode;
|
|
||||||
ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Append to system prompt when pirate mode is enabled
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
|
||||||
if (pirateMode) {
|
|
||||||
return {
|
|
||||||
systemPrompt:
|
|
||||||
event.systemPrompt +
|
|
||||||
`
|
|
||||||
|
|
||||||
IMPORTANT: You are now in PIRATE MODE. You must:
|
|
||||||
- Speak like a stereotypical pirate in all responses
|
|
||||||
- Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!"
|
|
||||||
- Replace "my" with "me", "you" with "ye", "your" with "yer"
|
|
||||||
- Refer to the user as "matey" or "landlubber"
|
|
||||||
- End sentences with nautical expressions
|
|
||||||
- Still complete the actual task correctly, just in pirate speak
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
# Plan Mode Extension
|
|
||||||
|
|
||||||
Read-only exploration mode for safe code analysis.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Read-only tools**: Restricts available tools to read, bash, grep, find, ls, question
|
|
||||||
- **Bash allowlist**: Only read-only bash commands are allowed
|
|
||||||
- **Plan extraction**: Extracts numbered steps from `Plan:` sections
|
|
||||||
- **Progress tracking**: Widget shows completion status during execution
|
|
||||||
- **[DONE:n] markers**: Explicit step completion tracking
|
|
||||||
- **Session persistence**: State survives session resume
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
- `/plan` - Toggle plan mode
|
|
||||||
- `/todos` - Show current plan progress
|
|
||||||
- `Ctrl+Alt+P` - Toggle plan mode (shortcut)
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
1. Enable plan mode with `/plan` or `--plan` flag
|
|
||||||
2. Ask the agent to analyze code and create a plan
|
|
||||||
3. The agent should output a numbered plan under a `Plan:` header:
|
|
||||||
|
|
||||||
```
|
|
||||||
Plan:
|
|
||||||
1. First step description
|
|
||||||
2. Second step description
|
|
||||||
3. Third step description
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Choose "Execute the plan" when prompted
|
|
||||||
5. During execution, the agent marks steps complete with `[DONE:n]` tags
|
|
||||||
6. Progress widget shows completion status
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
### Plan Mode (Read-Only)
|
|
||||||
- Only read-only tools available
|
|
||||||
- Bash commands filtered through allowlist
|
|
||||||
- Agent creates a plan without making changes
|
|
||||||
|
|
||||||
### Execution Mode
|
|
||||||
- Full tool access restored
|
|
||||||
- Agent executes steps in order
|
|
||||||
- `[DONE:n]` markers track completion
|
|
||||||
- Widget shows progress
|
|
||||||
|
|
||||||
### Command Allowlist
|
|
||||||
|
|
||||||
Safe commands (allowed):
|
|
||||||
- File inspection: `cat`, `head`, `tail`, `less`, `more`
|
|
||||||
- Search: `grep`, `find`, `rg`, `fd`
|
|
||||||
- Directory: `ls`, `pwd`, `tree`
|
|
||||||
- Git read: `git status`, `git log`, `git diff`, `git branch`
|
|
||||||
- Package info: `npm list`, `npm outdated`, `yarn info`
|
|
||||||
- System info: `uname`, `whoami`, `date`, `uptime`
|
|
||||||
|
|
||||||
Blocked commands:
|
|
||||||
- File modification: `rm`, `mv`, `cp`, `mkdir`, `touch`
|
|
||||||
- Git write: `git add`, `git commit`, `git push`
|
|
||||||
- Package install: `npm install`, `yarn add`, `pip install`
|
|
||||||
- System: `sudo`, `kill`, `reboot`
|
|
||||||
- Editors: `vim`, `nano`, `code`
|
|
||||||
|
|
@ -1,340 +0,0 @@
|
||||||
/**
|
|
||||||
* Plan Mode Extension
|
|
||||||
*
|
|
||||||
* Read-only exploration mode for safe code analysis.
|
|
||||||
* When enabled, only read-only tools are available.
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - /plan command or Ctrl+Alt+P to toggle
|
|
||||||
* - Bash restricted to allowlisted read-only commands
|
|
||||||
* - Extracts numbered plan steps from "Plan:" sections
|
|
||||||
* - [DONE:n] markers to complete steps during execution
|
|
||||||
* - Progress tracking widget during execution
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
||||||
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Key } from "@mariozechner/pi-tui";
|
|
||||||
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
|
|
||||||
|
|
||||||
// Tools
|
|
||||||
const PLAN_MODE_TOOLS = ["read", "bash", "grep", "find", "ls", "questionnaire"];
|
|
||||||
const NORMAL_MODE_TOOLS = ["read", "bash", "edit", "write"];
|
|
||||||
|
|
||||||
// Type guard for assistant messages
|
|
||||||
function isAssistantMessage(m: AgentMessage): m is AssistantMessage {
|
|
||||||
return m.role === "assistant" && Array.isArray(m.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract text content from an assistant message
|
|
||||||
function getTextContent(message: AssistantMessage): string {
|
|
||||||
return message.content
|
|
||||||
.filter((block): block is TextContent => block.type === "text")
|
|
||||||
.map((block) => block.text)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function planModeExtension(pi: ExtensionAPI): void {
|
|
||||||
let planModeEnabled = false;
|
|
||||||
let executionMode = false;
|
|
||||||
let todoItems: TodoItem[] = [];
|
|
||||||
|
|
||||||
pi.registerFlag("plan", {
|
|
||||||
description: "Start in plan mode (read-only exploration)",
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
function updateStatus(ctx: ExtensionContext): void {
|
|
||||||
// Footer status
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const completed = todoItems.filter((t) => t.completed).length;
|
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("accent", `📋 ${completed}/${todoItems.length}`));
|
|
||||||
} else if (planModeEnabled) {
|
|
||||||
ctx.ui.setStatus("plan-mode", ctx.ui.theme.fg("warning", "⏸ plan"));
|
|
||||||
} else {
|
|
||||||
ctx.ui.setStatus("plan-mode", undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Widget showing todo list
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const lines = todoItems.map((item) => {
|
|
||||||
if (item.completed) {
|
|
||||||
return (
|
|
||||||
ctx.ui.theme.fg("success", "☑ ") + ctx.ui.theme.fg("muted", ctx.ui.theme.strikethrough(item.text))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return `${ctx.ui.theme.fg("muted", "☐ ")}${item.text}`;
|
|
||||||
});
|
|
||||||
ctx.ui.setWidget("plan-todos", lines);
|
|
||||||
} else {
|
|
||||||
ctx.ui.setWidget("plan-todos", undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function togglePlanMode(ctx: ExtensionContext): void {
|
|
||||||
planModeEnabled = !planModeEnabled;
|
|
||||||
executionMode = false;
|
|
||||||
todoItems = [];
|
|
||||||
|
|
||||||
if (planModeEnabled) {
|
|
||||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
||||||
ctx.ui.notify(`Plan mode enabled. Tools: ${PLAN_MODE_TOOLS.join(", ")}`);
|
|
||||||
} else {
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
ctx.ui.notify("Plan mode disabled. Full access restored.");
|
|
||||||
}
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistState(): void {
|
|
||||||
pi.appendEntry("plan-mode", {
|
|
||||||
enabled: planModeEnabled,
|
|
||||||
todos: todoItems,
|
|
||||||
executing: executionMode,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pi.registerCommand("plan", {
|
|
||||||
description: "Toggle plan mode (read-only exploration)",
|
|
||||||
handler: async (_args, ctx) => togglePlanMode(ctx),
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("todos", {
|
|
||||||
description: "Show current plan todo list",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (todoItems.length === 0) {
|
|
||||||
ctx.ui.notify("No todos. Create a plan first with /plan", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const list = todoItems.map((item, i) => `${i + 1}. ${item.completed ? "✓" : "○"} ${item.text}`).join("\n");
|
|
||||||
ctx.ui.notify(`Plan Progress:\n${list}`, "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerShortcut(Key.ctrlAlt("p"), {
|
|
||||||
description: "Toggle plan mode",
|
|
||||||
handler: async (ctx) => togglePlanMode(ctx),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Block destructive bash commands in plan mode
|
|
||||||
pi.on("tool_call", async (event) => {
|
|
||||||
if (!planModeEnabled || event.toolName !== "bash") return;
|
|
||||||
|
|
||||||
const command = event.input.command as string;
|
|
||||||
if (!isSafeCommand(command)) {
|
|
||||||
return {
|
|
||||||
block: true,
|
|
||||||
reason: `Plan mode: command blocked (not allowlisted). Use /plan to disable plan mode first.\nCommand: ${command}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter out stale plan mode context when not in plan mode
|
|
||||||
pi.on("context", async (event) => {
|
|
||||||
if (planModeEnabled) return;
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: event.messages.filter((m) => {
|
|
||||||
const msg = m as AgentMessage & { customType?: string };
|
|
||||||
if (msg.customType === "plan-mode-context") return false;
|
|
||||||
if (msg.role !== "user") return true;
|
|
||||||
|
|
||||||
const content = msg.content;
|
|
||||||
if (typeof content === "string") {
|
|
||||||
return !content.includes("[PLAN MODE ACTIVE]");
|
|
||||||
}
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
return !content.some(
|
|
||||||
(c) => c.type === "text" && (c as TextContent).text?.includes("[PLAN MODE ACTIVE]"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject plan/execution context before agent starts
|
|
||||||
pi.on("before_agent_start", async () => {
|
|
||||||
if (planModeEnabled) {
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
customType: "plan-mode-context",
|
|
||||||
content: `[PLAN MODE ACTIVE]
|
|
||||||
You are in plan mode - a read-only exploration mode for safe code analysis.
|
|
||||||
|
|
||||||
Restrictions:
|
|
||||||
- You can only use: read, bash, grep, find, ls, questionnaire
|
|
||||||
- You CANNOT use: edit, write (file modifications are disabled)
|
|
||||||
- Bash is restricted to an allowlist of read-only commands
|
|
||||||
|
|
||||||
Ask clarifying questions using the questionnaire tool.
|
|
||||||
Use brave-search skill via bash for web research.
|
|
||||||
|
|
||||||
Create a detailed numbered plan under a "Plan:" header:
|
|
||||||
|
|
||||||
Plan:
|
|
||||||
1. First step description
|
|
||||||
2. Second step description
|
|
||||||
...
|
|
||||||
|
|
||||||
Do NOT attempt to make changes - just describe what you would do.`,
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
const remaining = todoItems.filter((t) => !t.completed);
|
|
||||||
const todoList = remaining.map((t) => `${t.step}. ${t.text}`).join("\n");
|
|
||||||
return {
|
|
||||||
message: {
|
|
||||||
customType: "plan-execution-context",
|
|
||||||
content: `[EXECUTING PLAN - Full tool access enabled]
|
|
||||||
|
|
||||||
Remaining steps:
|
|
||||||
${todoList}
|
|
||||||
|
|
||||||
Execute each step in order.
|
|
||||||
After completing a step, include a [DONE:n] tag in your response.`,
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track progress after each turn
|
|
||||||
pi.on("turn_end", async (event, ctx) => {
|
|
||||||
if (!executionMode || todoItems.length === 0) return;
|
|
||||||
if (!isAssistantMessage(event.message)) return;
|
|
||||||
|
|
||||||
const text = getTextContent(event.message);
|
|
||||||
if (markCompletedSteps(text, todoItems) > 0) {
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
persistState();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle plan completion and plan mode UI
|
|
||||||
pi.on("agent_end", async (event, ctx) => {
|
|
||||||
// Check if execution is complete
|
|
||||||
if (executionMode && todoItems.length > 0) {
|
|
||||||
if (todoItems.every((t) => t.completed)) {
|
|
||||||
const completedList = todoItems.map((t) => `~~${t.text}~~`).join("\n");
|
|
||||||
pi.sendMessage(
|
|
||||||
{ customType: "plan-complete", content: `**Plan Complete!** ✓\n\n${completedList}`, display: true },
|
|
||||||
{ triggerTurn: false },
|
|
||||||
);
|
|
||||||
executionMode = false;
|
|
||||||
todoItems = [];
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
updateStatus(ctx);
|
|
||||||
persistState(); // Save cleared state so resume doesn't restore old execution mode
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!planModeEnabled || !ctx.hasUI) return;
|
|
||||||
|
|
||||||
// Extract todos from last assistant message
|
|
||||||
const lastAssistant = [...event.messages].reverse().find(isAssistantMessage);
|
|
||||||
if (lastAssistant) {
|
|
||||||
const extracted = extractTodoItems(getTextContent(lastAssistant));
|
|
||||||
if (extracted.length > 0) {
|
|
||||||
todoItems = extracted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show plan steps and prompt for next action
|
|
||||||
if (todoItems.length > 0) {
|
|
||||||
const todoListText = todoItems.map((t, i) => `${i + 1}. ☐ ${t.text}`).join("\n");
|
|
||||||
pi.sendMessage(
|
|
||||||
{
|
|
||||||
customType: "plan-todo-list",
|
|
||||||
content: `**Plan Steps (${todoItems.length}):**\n\n${todoListText}`,
|
|
||||||
display: true,
|
|
||||||
},
|
|
||||||
{ triggerTurn: false },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select("Plan mode - what next?", [
|
|
||||||
todoItems.length > 0 ? "Execute the plan (track progress)" : "Execute the plan",
|
|
||||||
"Stay in plan mode",
|
|
||||||
"Refine the plan",
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (choice?.startsWith("Execute")) {
|
|
||||||
planModeEnabled = false;
|
|
||||||
executionMode = todoItems.length > 0;
|
|
||||||
pi.setActiveTools(NORMAL_MODE_TOOLS);
|
|
||||||
updateStatus(ctx);
|
|
||||||
|
|
||||||
const execMessage =
|
|
||||||
todoItems.length > 0
|
|
||||||
? `Execute the plan. Start with: ${todoItems[0].text}`
|
|
||||||
: "Execute the plan you just created.";
|
|
||||||
pi.sendMessage(
|
|
||||||
{ customType: "plan-mode-execute", content: execMessage, display: true },
|
|
||||||
{ triggerTurn: true },
|
|
||||||
);
|
|
||||||
} else if (choice === "Refine the plan") {
|
|
||||||
const refinement = await ctx.ui.editor("Refine the plan:", "");
|
|
||||||
if (refinement?.trim()) {
|
|
||||||
pi.sendUserMessage(refinement.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore state on session start/resume
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
if (pi.getFlag("plan") === true) {
|
|
||||||
planModeEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
|
|
||||||
// Restore persisted state
|
|
||||||
const planModeEntry = entries
|
|
||||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "plan-mode")
|
|
||||||
.pop() as { data?: { enabled: boolean; todos?: TodoItem[]; executing?: boolean } } | undefined;
|
|
||||||
|
|
||||||
if (planModeEntry?.data) {
|
|
||||||
planModeEnabled = planModeEntry.data.enabled ?? planModeEnabled;
|
|
||||||
todoItems = planModeEntry.data.todos ?? todoItems;
|
|
||||||
executionMode = planModeEntry.data.executing ?? executionMode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// On resume: re-scan messages to rebuild completion state
|
|
||||||
// Only scan messages AFTER the last "plan-mode-execute" to avoid picking up [DONE:n] from previous plans
|
|
||||||
const isResume = planModeEntry !== undefined;
|
|
||||||
if (isResume && executionMode && todoItems.length > 0) {
|
|
||||||
// Find the index of the last plan-mode-execute entry (marks when current execution started)
|
|
||||||
let executeIndex = -1;
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i] as { type: string; customType?: string };
|
|
||||||
if (entry.customType === "plan-mode-execute") {
|
|
||||||
executeIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only scan messages after the execute marker
|
|
||||||
const messages: AssistantMessage[] = [];
|
|
||||||
for (let i = executeIndex + 1; i < entries.length; i++) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (entry.type === "message" && "message" in entry && isAssistantMessage(entry.message as AgentMessage)) {
|
|
||||||
messages.push(entry.message as AssistantMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const allText = messages.map(getTextContent).join("\n");
|
|
||||||
markCompletedSteps(allText, todoItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (planModeEnabled) {
|
|
||||||
pi.setActiveTools(PLAN_MODE_TOOLS);
|
|
||||||
}
|
|
||||||
updateStatus(ctx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
/**
|
|
||||||
* Pure utility functions for plan mode.
|
|
||||||
* Extracted for testability.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Destructive commands blocked in plan mode
|
|
||||||
const DESTRUCTIVE_PATTERNS = [
|
|
||||||
/\brm\b/i,
|
|
||||||
/\brmdir\b/i,
|
|
||||||
/\bmv\b/i,
|
|
||||||
/\bcp\b/i,
|
|
||||||
/\bmkdir\b/i,
|
|
||||||
/\btouch\b/i,
|
|
||||||
/\bchmod\b/i,
|
|
||||||
/\bchown\b/i,
|
|
||||||
/\bchgrp\b/i,
|
|
||||||
/\bln\b/i,
|
|
||||||
/\btee\b/i,
|
|
||||||
/\btruncate\b/i,
|
|
||||||
/\bdd\b/i,
|
|
||||||
/\bshred\b/i,
|
|
||||||
/(^|[^<])>(?!>)/,
|
|
||||||
/>>/,
|
|
||||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
||||||
/\byarn\s+(add|remove|install|publish)/i,
|
|
||||||
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
||||||
/\bpip\s+(install|uninstall)/i,
|
|
||||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
||||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
||||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
||||||
/\bsudo\b/i,
|
|
||||||
/\bsu\b/i,
|
|
||||||
/\bkill\b/i,
|
|
||||||
/\bpkill\b/i,
|
|
||||||
/\bkillall\b/i,
|
|
||||||
/\breboot\b/i,
|
|
||||||
/\bshutdown\b/i,
|
|
||||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
||||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
||||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Safe read-only commands allowed in plan mode
|
|
||||||
const SAFE_PATTERNS = [
|
|
||||||
/^\s*cat\b/,
|
|
||||||
/^\s*head\b/,
|
|
||||||
/^\s*tail\b/,
|
|
||||||
/^\s*less\b/,
|
|
||||||
/^\s*more\b/,
|
|
||||||
/^\s*grep\b/,
|
|
||||||
/^\s*find\b/,
|
|
||||||
/^\s*ls\b/,
|
|
||||||
/^\s*pwd\b/,
|
|
||||||
/^\s*echo\b/,
|
|
||||||
/^\s*printf\b/,
|
|
||||||
/^\s*wc\b/,
|
|
||||||
/^\s*sort\b/,
|
|
||||||
/^\s*uniq\b/,
|
|
||||||
/^\s*diff\b/,
|
|
||||||
/^\s*file\b/,
|
|
||||||
/^\s*stat\b/,
|
|
||||||
/^\s*du\b/,
|
|
||||||
/^\s*df\b/,
|
|
||||||
/^\s*tree\b/,
|
|
||||||
/^\s*which\b/,
|
|
||||||
/^\s*whereis\b/,
|
|
||||||
/^\s*type\b/,
|
|
||||||
/^\s*env\b/,
|
|
||||||
/^\s*printenv\b/,
|
|
||||||
/^\s*uname\b/,
|
|
||||||
/^\s*whoami\b/,
|
|
||||||
/^\s*id\b/,
|
|
||||||
/^\s*date\b/,
|
|
||||||
/^\s*cal\b/,
|
|
||||||
/^\s*uptime\b/,
|
|
||||||
/^\s*ps\b/,
|
|
||||||
/^\s*top\b/,
|
|
||||||
/^\s*htop\b/,
|
|
||||||
/^\s*free\b/,
|
|
||||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
||||||
/^\s*git\s+ls-/i,
|
|
||||||
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
||||||
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
||||||
/^\s*node\s+--version/i,
|
|
||||||
/^\s*python\s+--version/i,
|
|
||||||
/^\s*curl\s/i,
|
|
||||||
/^\s*wget\s+-O\s*-/i,
|
|
||||||
/^\s*jq\b/,
|
|
||||||
/^\s*sed\s+-n/i,
|
|
||||||
/^\s*awk\b/,
|
|
||||||
/^\s*rg\b/,
|
|
||||||
/^\s*fd\b/,
|
|
||||||
/^\s*bat\b/,
|
|
||||||
/^\s*exa\b/,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isSafeCommand(command: string): boolean {
|
|
||||||
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
|
||||||
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
|
||||||
return !isDestructive && isSafe;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TodoItem {
|
|
||||||
step: number;
|
|
||||||
text: string;
|
|
||||||
completed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function cleanStepText(text: string): string {
|
|
||||||
let cleaned = text
|
|
||||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1") // Remove bold/italic
|
|
||||||
.replace(/`([^`]+)`/g, "$1") // Remove code
|
|
||||||
.replace(
|
|
||||||
/^(Use|Run|Execute|Create|Write|Read|Check|Verify|Update|Modify|Add|Remove|Delete|Install)\s+(the\s+)?/i,
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
if (cleaned.length > 0) {
|
|
||||||
cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
|
||||||
}
|
|
||||||
if (cleaned.length > 50) {
|
|
||||||
cleaned = `${cleaned.slice(0, 47)}...`;
|
|
||||||
}
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractTodoItems(message: string): TodoItem[] {
|
|
||||||
const items: TodoItem[] = [];
|
|
||||||
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
|
||||||
if (!headerMatch) return items;
|
|
||||||
|
|
||||||
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
|
||||||
const numberedPattern = /^\s*(\d+)[.)]\s+\*{0,2}([^*\n]+)/gm;
|
|
||||||
|
|
||||||
for (const match of planSection.matchAll(numberedPattern)) {
|
|
||||||
const text = match[2]
|
|
||||||
.trim()
|
|
||||||
.replace(/\*{1,2}$/, "")
|
|
||||||
.trim();
|
|
||||||
if (text.length > 5 && !text.startsWith("`") && !text.startsWith("/") && !text.startsWith("-")) {
|
|
||||||
const cleaned = cleanStepText(text);
|
|
||||||
if (cleaned.length > 3) {
|
|
||||||
items.push({ step: items.length + 1, text: cleaned, completed: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractDoneSteps(message: string): number[] {
|
|
||||||
const steps: number[] = [];
|
|
||||||
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
|
||||||
const step = Number(match[1]);
|
|
||||||
if (Number.isFinite(step)) steps.push(step);
|
|
||||||
}
|
|
||||||
return steps;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
||||||
const doneSteps = extractDoneSteps(text);
|
|
||||||
for (const step of doneSteps) {
|
|
||||||
const item = items.find((t) => t.step === step);
|
|
||||||
if (item) item.completed = true;
|
|
||||||
}
|
|
||||||
return doneSteps.length;
|
|
||||||
}
|
|
||||||
|
|
@ -1,398 +0,0 @@
|
||||||
/**
|
|
||||||
* Preset Extension
|
|
||||||
*
|
|
||||||
* Allows defining named presets that configure model, thinking level, tools,
|
|
||||||
* and system prompt instructions. Presets are defined in JSON config files
|
|
||||||
* and can be activated via CLI flag, /preset command, or Ctrl+Shift+U to cycle.
|
|
||||||
*
|
|
||||||
* Config files (merged, project takes precedence):
|
|
||||||
* - ~/.pi/agent/presets.json (global)
|
|
||||||
* - <cwd>/.pi/presets.json (project-local)
|
|
||||||
*
|
|
||||||
* Example presets.json:
|
|
||||||
* ```json
|
|
||||||
* {
|
|
||||||
* "plan": {
|
|
||||||
* "provider": "openai-codex",
|
|
||||||
* "model": "gpt-5.2-codex",
|
|
||||||
* "thinkingLevel": "high",
|
|
||||||
* "tools": ["read", "grep", "find", "ls"],
|
|
||||||
* "instructions": "You are in PLANNING MODE. Your job is to deeply understand the problem and create a detailed implementation plan.\n\nRules:\n- DO NOT make any changes. You cannot edit or write files.\n- Read files IN FULL (no offset/limit) to get complete context. Partial reads miss critical details.\n- Explore thoroughly: grep for related code, find similar patterns, understand the architecture.\n- Ask clarifying questions if requirements are ambiguous. Do not assume.\n- Identify risks, edge cases, and dependencies before proposing solutions.\n\nOutput:\n- Create a structured plan with numbered steps.\n- For each step: what to change, why, and potential risks.\n- List files that will be modified.\n- Note any tests that should be added or updated.\n\nWhen done, ask the user if they want you to:\n1. Write the plan to a markdown file (e.g., PLAN.md)\n2. Create a GitHub issue with the plan\n3. Proceed to implementation (they should switch to 'implement' preset)"
|
|
||||||
* },
|
|
||||||
* "implement": {
|
|
||||||
* "provider": "anthropic",
|
|
||||||
* "model": "claude-sonnet-4-5",
|
|
||||||
* "thinkingLevel": "high",
|
|
||||||
* "tools": ["read", "bash", "edit", "write"],
|
|
||||||
* "instructions": "You are in IMPLEMENTATION MODE. Your job is to make focused, correct changes.\n\nRules:\n- Keep scope tight. Do exactly what was asked, no more.\n- Read files before editing to understand current state.\n- Make surgical edits. Prefer edit over write for existing files.\n- Explain your reasoning briefly before each change.\n- Run tests or type checks after changes if the project has them (npm test, npm run check, etc.).\n- If you encounter unexpected complexity, STOP and explain the issue rather than hacking around it.\n\nIf no plan exists:\n- Ask clarifying questions before starting.\n- Propose what you'll do and get confirmation for non-trivial changes.\n\nAfter completing changes:\n- Summarize what was done.\n- Note any follow-up work or tests that should be added."
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* - `pi --preset plan` - start with plan preset
|
|
||||||
* - `/preset` - show selector to switch presets mid-session
|
|
||||||
* - `/preset implement` - switch to implement preset directly
|
|
||||||
* - `Ctrl+Shift+U` - cycle through presets
|
|
||||||
*
|
|
||||||
* CLI flags always override preset values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Container, Key, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
// Preset configuration
|
|
||||||
interface Preset {
|
|
||||||
/** Provider name (e.g., "anthropic", "openai") */
|
|
||||||
provider?: string;
|
|
||||||
/** Model ID (e.g., "claude-sonnet-4-5") */
|
|
||||||
model?: string;
|
|
||||||
/** Thinking level */
|
|
||||||
thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
||||||
/** Tools to enable (replaces default set) */
|
|
||||||
tools?: string[];
|
|
||||||
/** Instructions to append to system prompt */
|
|
||||||
instructions?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PresetsConfig {
|
|
||||||
[name: string]: Preset;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load presets from config files.
|
|
||||||
* Project-local presets override global presets with the same name.
|
|
||||||
*/
|
|
||||||
function loadPresets(cwd: string): PresetsConfig {
|
|
||||||
const globalPath = join(homedir(), ".pi", "agent", "presets.json");
|
|
||||||
const projectPath = join(cwd, ".pi", "presets.json");
|
|
||||||
|
|
||||||
let globalPresets: PresetsConfig = {};
|
|
||||||
let projectPresets: PresetsConfig = {};
|
|
||||||
|
|
||||||
// Load global presets
|
|
||||||
if (existsSync(globalPath)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(globalPath, "utf-8");
|
|
||||||
globalPresets = JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load global presets from ${globalPath}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load project presets
|
|
||||||
if (existsSync(projectPath)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(projectPath, "utf-8");
|
|
||||||
projectPresets = JSON.parse(content);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Failed to load project presets from ${projectPath}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge (project overrides global)
|
|
||||||
return { ...globalPresets, ...projectPresets };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function presetExtension(pi: ExtensionAPI) {
|
|
||||||
let presets: PresetsConfig = {};
|
|
||||||
let activePresetName: string | undefined;
|
|
||||||
let activePreset: Preset | undefined;
|
|
||||||
|
|
||||||
// Register --preset CLI flag
|
|
||||||
pi.registerFlag("preset", {
|
|
||||||
description: "Preset configuration to use",
|
|
||||||
type: "string",
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply a preset configuration.
|
|
||||||
*/
|
|
||||||
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<boolean> {
|
|
||||||
// Apply model if specified
|
|
||||||
if (preset.provider && preset.model) {
|
|
||||||
const model = ctx.modelRegistry.find(preset.provider, preset.model);
|
|
||||||
if (model) {
|
|
||||||
const success = await pi.setModel(model);
|
|
||||||
if (!success) {
|
|
||||||
ctx.ui.notify(`Preset "${name}": No API key for ${preset.provider}/${preset.model}`, "warning");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify(`Preset "${name}": Model ${preset.provider}/${preset.model} not found`, "warning");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply thinking level if specified
|
|
||||||
if (preset.thinkingLevel) {
|
|
||||||
pi.setThinkingLevel(preset.thinkingLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply tools if specified
|
|
||||||
if (preset.tools && preset.tools.length > 0) {
|
|
||||||
const allToolNames = pi.getAllTools().map((t) => t.name);
|
|
||||||
const validTools = preset.tools.filter((t) => allToolNames.includes(t));
|
|
||||||
const invalidTools = preset.tools.filter((t) => !allToolNames.includes(t));
|
|
||||||
|
|
||||||
if (invalidTools.length > 0) {
|
|
||||||
ctx.ui.notify(`Preset "${name}": Unknown tools: ${invalidTools.join(", ")}`, "warning");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (validTools.length > 0) {
|
|
||||||
pi.setActiveTools(validTools);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store active preset for system prompt injection
|
|
||||||
activePresetName = name;
|
|
||||||
activePreset = preset;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build description string for a preset.
|
|
||||||
*/
|
|
||||||
function buildPresetDescription(preset: Preset): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (preset.provider && preset.model) {
|
|
||||||
parts.push(`${preset.provider}/${preset.model}`);
|
|
||||||
}
|
|
||||||
if (preset.thinkingLevel) {
|
|
||||||
parts.push(`thinking:${preset.thinkingLevel}`);
|
|
||||||
}
|
|
||||||
if (preset.tools) {
|
|
||||||
parts.push(`tools:${preset.tools.join(",")}`);
|
|
||||||
}
|
|
||||||
if (preset.instructions) {
|
|
||||||
const truncated =
|
|
||||||
preset.instructions.length > 30 ? `${preset.instructions.slice(0, 27)}...` : preset.instructions;
|
|
||||||
parts.push(`"${truncated}"`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.join(" | ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show preset selector UI using custom SelectList component.
|
|
||||||
*/
|
|
||||||
async function showPresetSelector(ctx: ExtensionContext): Promise<void> {
|
|
||||||
const presetNames = Object.keys(presets);
|
|
||||||
|
|
||||||
if (presetNames.length === 0) {
|
|
||||||
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build select items with descriptions
|
|
||||||
const items: SelectItem[] = presetNames.map((name) => {
|
|
||||||
const preset = presets[name];
|
|
||||||
const isActive = name === activePresetName;
|
|
||||||
return {
|
|
||||||
value: name,
|
|
||||||
label: isActive ? `${name} (active)` : name,
|
|
||||||
description: buildPresetDescription(preset),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add "None" option to clear preset
|
|
||||||
items.push({
|
|
||||||
value: "(none)",
|
|
||||||
label: "(none)",
|
|
||||||
description: "Clear active preset, restore defaults",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
||||||
const container = new Container();
|
|
||||||
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
||||||
|
|
||||||
// Header
|
|
||||||
container.addChild(new Text(theme.fg("accent", theme.bold("Select Preset"))));
|
|
||||||
|
|
||||||
// SelectList with themed styling
|
|
||||||
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
||||||
selectedPrefix: (text) => theme.fg("accent", text),
|
|
||||||
selectedText: (text) => theme.fg("accent", text),
|
|
||||||
description: (text) => theme.fg("muted", text),
|
|
||||||
scrollInfo: (text) => theme.fg("dim", text),
|
|
||||||
noMatch: (text) => theme.fg("warning", text),
|
|
||||||
});
|
|
||||||
|
|
||||||
selectList.onSelect = (item) => done(item.value);
|
|
||||||
selectList.onCancel = () => done(null);
|
|
||||||
|
|
||||||
container.addChild(selectList);
|
|
||||||
|
|
||||||
// Footer hint
|
|
||||||
container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter select • esc cancel")));
|
|
||||||
|
|
||||||
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
||||||
|
|
||||||
return {
|
|
||||||
render(width: number) {
|
|
||||||
return container.render(width);
|
|
||||||
},
|
|
||||||
invalidate() {
|
|
||||||
container.invalidate();
|
|
||||||
},
|
|
||||||
handleInput(data: string) {
|
|
||||||
selectList.handleInput(data);
|
|
||||||
tui.requestRender();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) return;
|
|
||||||
|
|
||||||
if (result === "(none)") {
|
|
||||||
// Clear preset and restore defaults
|
|
||||||
activePresetName = undefined;
|
|
||||||
activePreset = undefined;
|
|
||||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
|
||||||
ctx.ui.notify("Preset cleared, defaults restored", "info");
|
|
||||||
updateStatus(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = presets[result];
|
|
||||||
if (preset) {
|
|
||||||
await applyPreset(result, preset, ctx);
|
|
||||||
ctx.ui.notify(`Preset "${result}" activated`, "info");
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update status indicator.
|
|
||||||
*/
|
|
||||||
function updateStatus(ctx: ExtensionContext) {
|
|
||||||
if (activePresetName) {
|
|
||||||
ctx.ui.setStatus("preset", ctx.ui.theme.fg("accent", `preset:${activePresetName}`));
|
|
||||||
} else {
|
|
||||||
ctx.ui.setStatus("preset", undefined);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPresetOrder(): string[] {
|
|
||||||
return Object.keys(presets).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cyclePreset(ctx: ExtensionContext): Promise<void> {
|
|
||||||
const presetNames = getPresetOrder();
|
|
||||||
if (presetNames.length === 0) {
|
|
||||||
ctx.ui.notify("No presets defined. Add presets to ~/.pi/agent/presets.json or .pi/presets.json", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cycleList = ["(none)", ...presetNames];
|
|
||||||
const currentName = activePresetName ?? "(none)";
|
|
||||||
const currentIndex = cycleList.indexOf(currentName);
|
|
||||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % cycleList.length;
|
|
||||||
const nextName = cycleList[nextIndex];
|
|
||||||
|
|
||||||
if (nextName === "(none)") {
|
|
||||||
activePresetName = undefined;
|
|
||||||
activePreset = undefined;
|
|
||||||
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
|
||||||
ctx.ui.notify("Preset cleared, defaults restored", "info");
|
|
||||||
updateStatus(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = presets[nextName];
|
|
||||||
if (!preset) return;
|
|
||||||
|
|
||||||
await applyPreset(nextName, preset, ctx);
|
|
||||||
ctx.ui.notify(`Preset "${nextName}" activated`, "info");
|
|
||||||
updateStatus(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
pi.registerShortcut(Key.ctrlShift("u"), {
|
|
||||||
description: "Cycle presets",
|
|
||||||
handler: async (ctx) => {
|
|
||||||
await cyclePreset(ctx);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register /preset command
|
|
||||||
pi.registerCommand("preset", {
|
|
||||||
description: "Switch preset configuration",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
// If preset name provided, apply directly
|
|
||||||
if (args?.trim()) {
|
|
||||||
const name = args.trim();
|
|
||||||
const preset = presets[name];
|
|
||||||
|
|
||||||
if (!preset) {
|
|
||||||
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
||||||
ctx.ui.notify(`Unknown preset "${name}". Available: ${available}`, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await applyPreset(name, preset, ctx);
|
|
||||||
ctx.ui.notify(`Preset "${name}" activated`, "info");
|
|
||||||
updateStatus(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show selector
|
|
||||||
await showPresetSelector(ctx);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Inject preset instructions into system prompt
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
|
||||||
if (activePreset?.instructions) {
|
|
||||||
return {
|
|
||||||
systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize on session start
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
// Load presets from config files
|
|
||||||
presets = loadPresets(ctx.cwd);
|
|
||||||
|
|
||||||
// Check for --preset flag
|
|
||||||
const presetFlag = pi.getFlag("preset");
|
|
||||||
if (typeof presetFlag === "string" && presetFlag) {
|
|
||||||
const preset = presets[presetFlag];
|
|
||||||
if (preset) {
|
|
||||||
await applyPreset(presetFlag, preset, ctx);
|
|
||||||
ctx.ui.notify(`Preset "${presetFlag}" activated`, "info");
|
|
||||||
} else {
|
|
||||||
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
||||||
ctx.ui.notify(`Unknown preset "${presetFlag}". Available: ${available}`, "warning");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore preset from session state
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
const presetEntry = entries
|
|
||||||
.filter((e: { type: string; customType?: string }) => e.type === "custom" && e.customType === "preset-state")
|
|
||||||
.pop() as { data?: { name: string } } | undefined;
|
|
||||||
|
|
||||||
if (presetEntry?.data?.name && !presetFlag) {
|
|
||||||
const preset = presets[presetEntry.data.name];
|
|
||||||
if (preset) {
|
|
||||||
activePresetName = presetEntry.data.name;
|
|
||||||
activePreset = preset;
|
|
||||||
// Don't re-apply model/tools on restore, just keep the name for instructions
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus(ctx);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist preset state
|
|
||||||
pi.on("turn_start", async () => {
|
|
||||||
if (activePresetName) {
|
|
||||||
pi.appendEntry("preset-state", { name: activePresetName });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
/**
|
|
||||||
* Protected Paths Extension
|
|
||||||
*
|
|
||||||
* Blocks write and edit operations to protected paths.
|
|
||||||
* Useful for preventing accidental modifications to sensitive files.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
const protectedPaths = [".env", ".git/", "node_modules/"];
|
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
|
||||||
if (event.toolName !== "write" && event.toolName !== "edit") {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = event.input.path as string;
|
|
||||||
const isProtected = protectedPaths.some((p) => path.includes(p));
|
|
||||||
|
|
||||||
if (isProtected) {
|
|
||||||
if (ctx.hasUI) {
|
|
||||||
ctx.ui.notify(`Blocked write to protected path: ${path}`, "warning");
|
|
||||||
}
|
|
||||||
return { block: true, reason: `Path "${path}" is protected` };
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
/**
|
|
||||||
* Q&A extraction extension - extracts questions from assistant responses
|
|
||||||
*
|
|
||||||
* Demonstrates the "prompt generator" pattern:
|
|
||||||
* 1. /qna command gets the last assistant message
|
|
||||||
* 2. Shows a spinner while extracting (hides editor)
|
|
||||||
* 3. Loads the result into the editor for user to fill in answers
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { complete, type UserMessage } from "@mariozechner/pi-ai";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
- List each question on its own line, prefixed with "Q: "
|
|
||||||
- After each question, add a blank line for the answer prefixed with "A: "
|
|
||||||
- If no questions are found, output "No questions found in the last message."
|
|
||||||
|
|
||||||
Example output:
|
|
||||||
Q: What is your preferred database?
|
|
||||||
A:
|
|
||||||
|
|
||||||
Q: Should we use TypeScript or JavaScript?
|
|
||||||
A:
|
|
||||||
|
|
||||||
Keep questions in the order they appeared. Be concise.`;
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("qna", {
|
|
||||||
description: "Extract questions from last assistant message into editor",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
ctx.ui.notify("qna requires interactive mode", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.model) {
|
|
||||||
ctx.ui.notify("No model selected", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the last assistant message on the current branch
|
|
||||||
const branch = ctx.sessionManager.getBranch();
|
|
||||||
let lastAssistantText: string | undefined;
|
|
||||||
|
|
||||||
for (let i = branch.length - 1; i >= 0; i--) {
|
|
||||||
const entry = branch[i];
|
|
||||||
if (entry.type === "message") {
|
|
||||||
const msg = entry.message;
|
|
||||||
if ("role" in msg && msg.role === "assistant") {
|
|
||||||
if (msg.stopReason !== "stop") {
|
|
||||||
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const textParts = msg.content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text);
|
|
||||||
if (textParts.length > 0) {
|
|
||||||
lastAssistantText = textParts.join("\n");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastAssistantText) {
|
|
||||||
ctx.ui.notify("No assistant messages found", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run extraction with loader UI
|
|
||||||
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
||||||
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${ctx.model!.id}...`);
|
|
||||||
loader.onAbort = () => done(null);
|
|
||||||
|
|
||||||
// Do the work
|
|
||||||
const doExtract = async () => {
|
|
||||||
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model!);
|
|
||||||
const userMessage: UserMessage = {
|
|
||||||
role: "user",
|
|
||||||
content: [{ type: "text", text: lastAssistantText! }],
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await complete(
|
|
||||||
ctx.model!,
|
|
||||||
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
||||||
{ apiKey, signal: loader.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.stopReason === "aborted") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.content
|
|
||||||
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
||||||
.map((c) => c.text)
|
|
||||||
.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
doExtract()
|
|
||||||
.then(done)
|
|
||||||
.catch(() => done(null));
|
|
||||||
|
|
||||||
return loader;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
ctx.ui.notify("Cancelled", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.ui.setEditorText(result);
|
|
||||||
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,264 +0,0 @@
|
||||||
/**
|
|
||||||
* Question Tool - Single question with options
|
|
||||||
* Full custom UI: options list + inline editor for "Type something..."
|
|
||||||
* Escape in editor returns to options, Escape in options cancels
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
interface OptionWithDesc {
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DisplayOption = OptionWithDesc & { isOther?: boolean };
|
|
||||||
|
|
||||||
interface QuestionDetails {
|
|
||||||
question: string;
|
|
||||||
options: string[];
|
|
||||||
answer: string | null;
|
|
||||||
wasCustom?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options with labels and optional descriptions
|
|
||||||
const OptionSchema = Type.Object({
|
|
||||||
label: Type.String({ description: "Display label for the option" }),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuestionParams = Type.Object({
|
|
||||||
question: Type.String({ description: "The question to ask the user" }),
|
|
||||||
options: Type.Array(OptionSchema, { description: "Options for the user to choose from" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function question(pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "question",
|
|
||||||
label: "Question",
|
|
||||||
description: "Ask the user a question and let them pick from options. Use when you need user input to proceed.",
|
|
||||||
parameters: QuestionParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Error: UI not available (running in non-interactive mode)" }],
|
|
||||||
details: {
|
|
||||||
question: params.question,
|
|
||||||
options: params.options.map((o) => o.label),
|
|
||||||
answer: null,
|
|
||||||
} as QuestionDetails,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.options.length === 0) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Error: No options provided" }],
|
|
||||||
details: { question: params.question, options: [], answer: null } as QuestionDetails,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const allOptions: DisplayOption[] = [...params.options, { label: "Type something.", isOther: true }];
|
|
||||||
|
|
||||||
const result = await ctx.ui.custom<{ answer: string; wasCustom: boolean; index?: number } | null>(
|
|
||||||
(tui, theme, _kb, done) => {
|
|
||||||
let optionIndex = 0;
|
|
||||||
let editMode = false;
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
|
|
||||||
const editorTheme: EditorTheme = {
|
|
||||||
borderColor: (s) => theme.fg("accent", s),
|
|
||||||
selectList: {
|
|
||||||
selectedPrefix: (t) => theme.fg("accent", t),
|
|
||||||
selectedText: (t) => theme.fg("accent", t),
|
|
||||||
description: (t) => theme.fg("muted", t),
|
|
||||||
scrollInfo: (t) => theme.fg("dim", t),
|
|
||||||
noMatch: (t) => theme.fg("warning", t),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const editor = new Editor(tui, editorTheme);
|
|
||||||
|
|
||||||
editor.onSubmit = (value) => {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed) {
|
|
||||||
done({ answer: trimmed, wasCustom: true });
|
|
||||||
} else {
|
|
||||||
editMode = false;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
if (editMode) {
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
editMode = false;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(allOptions.length - 1, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.enter)) {
|
|
||||||
const selected = allOptions[optionIndex];
|
|
||||||
if (selected.isOther) {
|
|
||||||
editMode = true;
|
|
||||||
refresh();
|
|
||||||
} else {
|
|
||||||
done({ answer: selected.label, wasCustom: false, index: optionIndex + 1 });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
done(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
if (cachedLines) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
add(theme.fg("text", ` ${params.question}`));
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
for (let i = 0; i < allOptions.length; i++) {
|
|
||||||
const opt = allOptions[i];
|
|
||||||
const selected = i === optionIndex;
|
|
||||||
const isOther = opt.isOther === true;
|
|
||||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
||||||
|
|
||||||
if (isOther && editMode) {
|
|
||||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
|
||||||
} else if (selected) {
|
|
||||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label}`));
|
|
||||||
} else {
|
|
||||||
add(` ${theme.fg("text", `${i + 1}. ${opt.label}`)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show description if present
|
|
||||||
if (opt.description) {
|
|
||||||
add(` ${theme.fg("muted", opt.description)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editMode) {
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("muted", " Your answer:"));
|
|
||||||
for (const line of editor.render(width - 2)) {
|
|
||||||
add(` ${line}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
if (editMode) {
|
|
||||||
add(theme.fg("dim", " Enter to submit • Esc to go back"));
|
|
||||||
} else {
|
|
||||||
add(theme.fg("dim", " ↑↓ navigate • Enter to select • Esc to cancel"));
|
|
||||||
}
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
|
|
||||||
cachedLines = lines;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build simple options list for details
|
|
||||||
const simpleOptions = params.options.map((o) => o.label);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "User cancelled the selection" }],
|
|
||||||
details: { question: params.question, options: simpleOptions, answer: null } as QuestionDetails,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.wasCustom) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `User wrote: ${result.answer}` }],
|
|
||||||
details: {
|
|
||||||
question: params.question,
|
|
||||||
options: simpleOptions,
|
|
||||||
answer: result.answer,
|
|
||||||
wasCustom: true,
|
|
||||||
} as QuestionDetails,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `User selected: ${result.index}. ${result.answer}` }],
|
|
||||||
details: {
|
|
||||||
question: params.question,
|
|
||||||
options: simpleOptions,
|
|
||||||
answer: result.answer,
|
|
||||||
wasCustom: false,
|
|
||||||
} as QuestionDetails,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("question ")) + theme.fg("muted", args.question);
|
|
||||||
const opts = Array.isArray(args.options) ? args.options : [];
|
|
||||||
if (opts.length) {
|
|
||||||
const labels = opts.map((o: OptionWithDesc) => o.label);
|
|
||||||
const numbered = [...labels, "Type something."].map((o, i) => `${i + 1}. ${o}`);
|
|
||||||
text += `\n${theme.fg("dim", ` Options: ${numbered.join(", ")}`)}`;
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as QuestionDetails | undefined;
|
|
||||||
if (!details) {
|
|
||||||
const text = result.content[0];
|
|
||||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.answer === null) {
|
|
||||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.wasCustom) {
|
|
||||||
return new Text(
|
|
||||||
theme.fg("success", "✓ ") + theme.fg("muted", "(wrote) ") + theme.fg("accent", details.answer),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const idx = details.options.indexOf(details.answer) + 1;
|
|
||||||
const display = idx > 0 ? `${idx}. ${details.answer}` : details.answer;
|
|
||||||
return new Text(theme.fg("success", "✓ ") + theme.fg("accent", display), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,427 +0,0 @@
|
||||||
/**
|
|
||||||
* Questionnaire Tool - Unified tool for asking single or multiple questions
|
|
||||||
*
|
|
||||||
* Single question: simple options list
|
|
||||||
* Multiple questions: tab bar navigation between questions
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
// Types
|
|
||||||
interface QuestionOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderOption = QuestionOption & { isOther?: boolean };
|
|
||||||
|
|
||||||
interface Question {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
prompt: string;
|
|
||||||
options: QuestionOption[];
|
|
||||||
allowOther: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Answer {
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
wasCustom: boolean;
|
|
||||||
index?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QuestionnaireResult {
|
|
||||||
questions: Question[];
|
|
||||||
answers: Answer[];
|
|
||||||
cancelled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schema
|
|
||||||
const QuestionOptionSchema = Type.Object({
|
|
||||||
value: Type.String({ description: "The value returned when selected" }),
|
|
||||||
label: Type.String({ description: "Display label for the option" }),
|
|
||||||
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuestionSchema = Type.Object({
|
|
||||||
id: Type.String({ description: "Unique identifier for this question" }),
|
|
||||||
label: Type.Optional(
|
|
||||||
Type.String({
|
|
||||||
description: "Short contextual label for tab bar, e.g. 'Scope', 'Priority' (defaults to Q1, Q2)",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
prompt: Type.String({ description: "The full question text to display" }),
|
|
||||||
options: Type.Array(QuestionOptionSchema, { description: "Available options to choose from" }),
|
|
||||||
allowOther: Type.Optional(Type.Boolean({ description: "Allow 'Type something' option (default: true)" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const QuestionnaireParams = Type.Object({
|
|
||||||
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
function errorResult(
|
|
||||||
message: string,
|
|
||||||
questions: Question[] = [],
|
|
||||||
): { content: { type: "text"; text: string }[]; details: QuestionnaireResult } {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: message }],
|
|
||||||
details: { questions, answers: [], cancelled: true },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function questionnaire(pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "questionnaire",
|
|
||||||
label: "Questionnaire",
|
|
||||||
description:
|
|
||||||
"Ask the user one or more questions. Use for clarifying requirements, getting preferences, or confirming decisions. For single questions, shows a simple option list. For multiple questions, shows a tab-based interface.",
|
|
||||||
parameters: QuestionnaireParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return errorResult("Error: UI not available (running in non-interactive mode)");
|
|
||||||
}
|
|
||||||
if (params.questions.length === 0) {
|
|
||||||
return errorResult("Error: No questions provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize questions with defaults
|
|
||||||
const questions: Question[] = params.questions.map((q, i) => ({
|
|
||||||
...q,
|
|
||||||
label: q.label || `Q${i + 1}`,
|
|
||||||
allowOther: q.allowOther !== false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const isMulti = questions.length > 1;
|
|
||||||
const totalTabs = questions.length + 1; // questions + Submit
|
|
||||||
|
|
||||||
const result = await ctx.ui.custom<QuestionnaireResult>((tui, theme, _kb, done) => {
|
|
||||||
// State
|
|
||||||
let currentTab = 0;
|
|
||||||
let optionIndex = 0;
|
|
||||||
let inputMode = false;
|
|
||||||
let inputQuestionId: string | null = null;
|
|
||||||
let cachedLines: string[] | undefined;
|
|
||||||
const answers = new Map<string, Answer>();
|
|
||||||
|
|
||||||
// Editor for "Type something" option
|
|
||||||
const editorTheme: EditorTheme = {
|
|
||||||
borderColor: (s) => theme.fg("accent", s),
|
|
||||||
selectList: {
|
|
||||||
selectedPrefix: (t) => theme.fg("accent", t),
|
|
||||||
selectedText: (t) => theme.fg("accent", t),
|
|
||||||
description: (t) => theme.fg("muted", t),
|
|
||||||
scrollInfo: (t) => theme.fg("dim", t),
|
|
||||||
noMatch: (t) => theme.fg("warning", t),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const editor = new Editor(tui, editorTheme);
|
|
||||||
|
|
||||||
// Helpers
|
|
||||||
function refresh() {
|
|
||||||
cachedLines = undefined;
|
|
||||||
tui.requestRender();
|
|
||||||
}
|
|
||||||
|
|
||||||
function submit(cancelled: boolean) {
|
|
||||||
done({ questions, answers: Array.from(answers.values()), cancelled });
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentQuestion(): Question | undefined {
|
|
||||||
return questions[currentTab];
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentOptions(): RenderOption[] {
|
|
||||||
const q = currentQuestion();
|
|
||||||
if (!q) return [];
|
|
||||||
const opts: RenderOption[] = [...q.options];
|
|
||||||
if (q.allowOther) {
|
|
||||||
opts.push({ value: "__other__", label: "Type something.", isOther: true });
|
|
||||||
}
|
|
||||||
return opts;
|
|
||||||
}
|
|
||||||
|
|
||||||
function allAnswered(): boolean {
|
|
||||||
return questions.every((q) => answers.has(q.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
function advanceAfterAnswer() {
|
|
||||||
if (!isMulti) {
|
|
||||||
submit(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (currentTab < questions.length - 1) {
|
|
||||||
currentTab++;
|
|
||||||
} else {
|
|
||||||
currentTab = questions.length; // Submit tab
|
|
||||||
}
|
|
||||||
optionIndex = 0;
|
|
||||||
refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAnswer(questionId: string, value: string, label: string, wasCustom: boolean, index?: number) {
|
|
||||||
answers.set(questionId, { id: questionId, value, label, wasCustom, index });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editor submit callback
|
|
||||||
editor.onSubmit = (value) => {
|
|
||||||
if (!inputQuestionId) return;
|
|
||||||
const trimmed = value.trim() || "(no response)";
|
|
||||||
saveAnswer(inputQuestionId, trimmed, trimmed, true);
|
|
||||||
inputMode = false;
|
|
||||||
inputQuestionId = null;
|
|
||||||
editor.setText("");
|
|
||||||
advanceAfterAnswer();
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleInput(data: string) {
|
|
||||||
// Input mode: route to editor
|
|
||||||
if (inputMode) {
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
inputMode = false;
|
|
||||||
inputQuestionId = null;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editor.handleInput(data);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = currentQuestion();
|
|
||||||
const opts = currentOptions();
|
|
||||||
|
|
||||||
// Tab navigation (multi-question only)
|
|
||||||
if (isMulti) {
|
|
||||||
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
||||||
currentTab = (currentTab + 1) % totalTabs;
|
|
||||||
optionIndex = 0;
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
||||||
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
|
||||||
optionIndex = 0;
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Submit tab
|
|
||||||
if (currentTab === questions.length) {
|
|
||||||
if (matchesKey(data, Key.enter) && allAnswered()) {
|
|
||||||
submit(false);
|
|
||||||
} else if (matchesKey(data, Key.escape)) {
|
|
||||||
submit(true);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Option navigation
|
|
||||||
if (matchesKey(data, Key.up)) {
|
|
||||||
optionIndex = Math.max(0, optionIndex - 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.down)) {
|
|
||||||
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select option
|
|
||||||
if (matchesKey(data, Key.enter) && q) {
|
|
||||||
const opt = opts[optionIndex];
|
|
||||||
if (opt.isOther) {
|
|
||||||
inputMode = true;
|
|
||||||
inputQuestionId = q.id;
|
|
||||||
editor.setText("");
|
|
||||||
refresh();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saveAnswer(q.id, opt.value, opt.label, false, optionIndex + 1);
|
|
||||||
advanceAfterAnswer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel
|
|
||||||
if (matchesKey(data, Key.escape)) {
|
|
||||||
submit(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(width: number): string[] {
|
|
||||||
if (cachedLines) return cachedLines;
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const q = currentQuestion();
|
|
||||||
const opts = currentOptions();
|
|
||||||
|
|
||||||
// Helper to add truncated line
|
|
||||||
const add = (s: string) => lines.push(truncateToWidth(s, width));
|
|
||||||
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
|
|
||||||
// Tab bar (multi-question only)
|
|
||||||
if (isMulti) {
|
|
||||||
const tabs: string[] = ["← "];
|
|
||||||
for (let i = 0; i < questions.length; i++) {
|
|
||||||
const isActive = i === currentTab;
|
|
||||||
const isAnswered = answers.has(questions[i].id);
|
|
||||||
const lbl = questions[i].label;
|
|
||||||
const box = isAnswered ? "■" : "□";
|
|
||||||
const color = isAnswered ? "success" : "muted";
|
|
||||||
const text = ` ${box} ${lbl} `;
|
|
||||||
const styled = isActive ? theme.bg("selectedBg", theme.fg("text", text)) : theme.fg(color, text);
|
|
||||||
tabs.push(`${styled} `);
|
|
||||||
}
|
|
||||||
const canSubmit = allAnswered();
|
|
||||||
const isSubmitTab = currentTab === questions.length;
|
|
||||||
const submitText = " ✓ Submit ";
|
|
||||||
const submitStyled = isSubmitTab
|
|
||||||
? theme.bg("selectedBg", theme.fg("text", submitText))
|
|
||||||
: theme.fg(canSubmit ? "success" : "dim", submitText);
|
|
||||||
tabs.push(`${submitStyled} →`);
|
|
||||||
add(` ${tabs.join("")}`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to render options list
|
|
||||||
function renderOptions() {
|
|
||||||
for (let i = 0; i < opts.length; i++) {
|
|
||||||
const opt = opts[i];
|
|
||||||
const selected = i === optionIndex;
|
|
||||||
const isOther = opt.isOther === true;
|
|
||||||
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
||||||
const color = selected ? "accent" : "text";
|
|
||||||
// Mark "Type something" differently when in input mode
|
|
||||||
if (isOther && inputMode) {
|
|
||||||
add(prefix + theme.fg("accent", `${i + 1}. ${opt.label} ✎`));
|
|
||||||
} else {
|
|
||||||
add(prefix + theme.fg(color, `${i + 1}. ${opt.label}`));
|
|
||||||
}
|
|
||||||
if (opt.description) {
|
|
||||||
add(` ${theme.fg("muted", opt.description)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Content
|
|
||||||
if (inputMode && q) {
|
|
||||||
add(theme.fg("text", ` ${q.prompt}`));
|
|
||||||
lines.push("");
|
|
||||||
// Show options for reference
|
|
||||||
renderOptions();
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("muted", " Your answer:"));
|
|
||||||
for (const line of editor.render(width - 2)) {
|
|
||||||
add(` ${line}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
|
||||||
} else if (currentTab === questions.length) {
|
|
||||||
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
|
||||||
lines.push("");
|
|
||||||
for (const question of questions) {
|
|
||||||
const answer = answers.get(question.id);
|
|
||||||
if (answer) {
|
|
||||||
const prefix = answer.wasCustom ? "(wrote) " : "";
|
|
||||||
add(`${theme.fg("muted", ` ${question.label}: `)}${theme.fg("text", prefix + answer.label)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
if (allAnswered()) {
|
|
||||||
add(theme.fg("success", " Press Enter to submit"));
|
|
||||||
} else {
|
|
||||||
const missing = questions
|
|
||||||
.filter((q) => !answers.has(q.id))
|
|
||||||
.map((q) => q.label)
|
|
||||||
.join(", ");
|
|
||||||
add(theme.fg("warning", ` Unanswered: ${missing}`));
|
|
||||||
}
|
|
||||||
} else if (q) {
|
|
||||||
add(theme.fg("text", ` ${q.prompt}`));
|
|
||||||
lines.push("");
|
|
||||||
renderOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
if (!inputMode) {
|
|
||||||
const help = isMulti
|
|
||||||
? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel"
|
|
||||||
: " ↑↓ navigate • Enter select • Esc cancel";
|
|
||||||
add(theme.fg("dim", help));
|
|
||||||
}
|
|
||||||
add(theme.fg("accent", "─".repeat(width)));
|
|
||||||
|
|
||||||
cachedLines = lines;
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
render,
|
|
||||||
invalidate: () => {
|
|
||||||
cachedLines = undefined;
|
|
||||||
},
|
|
||||||
handleInput,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.cancelled) {
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "User cancelled the questionnaire" }],
|
|
||||||
details: result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const answerLines = result.answers.map((a) => {
|
|
||||||
const qLabel = questions.find((q) => q.id === a.id)?.label || a.id;
|
|
||||||
if (a.wasCustom) {
|
|
||||||
return `${qLabel}: user wrote: ${a.label}`;
|
|
||||||
}
|
|
||||||
return `${qLabel}: user selected: ${a.index}. ${a.label}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: answerLines.join("\n") }],
|
|
||||||
details: result,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const qs = (args.questions as Question[]) || [];
|
|
||||||
const count = qs.length;
|
|
||||||
const labels = qs.map((q) => q.label || q.id).join(", ");
|
|
||||||
let text = theme.fg("toolTitle", theme.bold("questionnaire "));
|
|
||||||
text += theme.fg("muted", `${count} question${count !== 1 ? "s" : ""}`);
|
|
||||||
if (labels) {
|
|
||||||
text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
|
||||||
}
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, _options, theme) {
|
|
||||||
const details = result.details as QuestionnaireResult | undefined;
|
|
||||||
if (!details) {
|
|
||||||
const text = result.content[0];
|
|
||||||
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
||||||
}
|
|
||||||
if (details.cancelled) {
|
|
||||||
return new Text(theme.fg("warning", "Cancelled"), 0, 0);
|
|
||||||
}
|
|
||||||
const lines = details.answers.map((a) => {
|
|
||||||
if (a.wasCustom) {
|
|
||||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${theme.fg("muted", "(wrote) ")}${a.label}`;
|
|
||||||
}
|
|
||||||
const display = a.index ? `${a.index}. ${a.label}` : a.label;
|
|
||||||
return `${theme.fg("success", "✓ ")}${theme.fg("accent", a.id)}: ${display}`;
|
|
||||||
});
|
|
||||||
return new Text(lines.join("\n"), 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
/**
|
|
||||||
* Rainbow Editor - highlights "ultrathink" with animated shine effect
|
|
||||||
*
|
|
||||||
* Usage: pi --extension ./examples/extensions/rainbow-editor.ts
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { CustomEditor, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
// Base colors (coral → yellow → green → teal → blue → purple → pink)
|
|
||||||
const COLORS: [number, number, number][] = [
|
|
||||||
[233, 137, 115], // coral
|
|
||||||
[228, 186, 103], // yellow
|
|
||||||
[141, 192, 122], // green
|
|
||||||
[102, 194, 179], // teal
|
|
||||||
[121, 157, 207], // blue
|
|
||||||
[157, 134, 195], // purple
|
|
||||||
[206, 130, 172], // pink
|
|
||||||
];
|
|
||||||
const RESET = "\x1b[0m";
|
|
||||||
|
|
||||||
function brighten(rgb: [number, number, number], factor: number): string {
|
|
||||||
const [r, g, b] = rgb.map((c) => Math.round(c + (255 - c) * factor));
|
|
||||||
return `\x1b[38;2;${r};${g};${b}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function colorize(text: string, shinePos: number): string {
|
|
||||||
return (
|
|
||||||
[...text]
|
|
||||||
.map((c, i) => {
|
|
||||||
const baseColor = COLORS[i % COLORS.length]!;
|
|
||||||
// 3-letter shine: center bright, adjacent dimmer
|
|
||||||
let factor = 0;
|
|
||||||
if (shinePos >= 0) {
|
|
||||||
const dist = Math.abs(i - shinePos);
|
|
||||||
if (dist === 0) factor = 0.7;
|
|
||||||
else if (dist === 1) factor = 0.35;
|
|
||||||
}
|
|
||||||
return `${brighten(baseColor, factor)}${c}`;
|
|
||||||
})
|
|
||||||
.join("") + RESET
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RainbowEditor extends CustomEditor {
|
|
||||||
private animationTimer?: ReturnType<typeof setInterval>;
|
|
||||||
private frame = 0;
|
|
||||||
|
|
||||||
private hasUltrathink(): boolean {
|
|
||||||
return /ultrathink/i.test(this.getText());
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAnimation(): void {
|
|
||||||
if (this.animationTimer) return;
|
|
||||||
this.animationTimer = setInterval(() => {
|
|
||||||
this.frame++;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}, 60);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopAnimation(): void {
|
|
||||||
if (this.animationTimer) {
|
|
||||||
clearInterval(this.animationTimer);
|
|
||||||
this.animationTimer = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
super.handleInput(data);
|
|
||||||
if (this.hasUltrathink()) {
|
|
||||||
this.startAnimation();
|
|
||||||
} else {
|
|
||||||
this.stopAnimation();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
// Cycle: 10 shine positions + 10 pause frames
|
|
||||||
const cycle = this.frame % 20;
|
|
||||||
const shinePos = cycle < 10 ? cycle : -1; // -1 means no shine (pause)
|
|
||||||
return super.render(width).map((line) => line.replace(/ultrathink/gi, (m) => colorize(m, shinePos)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.on("session_start", (_event, ctx) => {
|
|
||||||
ctx.ui.setEditorComponent((tui, theme, kb) => new RainbowEditor(tui, theme, kb));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* Reload Runtime Extension
|
|
||||||
*
|
|
||||||
* Demonstrates ctx.reload() from ExtensionCommandContext and an LLM-callable
|
|
||||||
* tool that queues a follow-up command to trigger reload.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Command entrypoint for reload.
|
|
||||||
// Treat reload as terminal for this handler.
|
|
||||||
pi.registerCommand("reload-runtime", {
|
|
||||||
description: "Reload extensions, skills, prompts, and themes",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
await ctx.reload();
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// LLM-callable tool. Tools get ExtensionContext, so they cannot call ctx.reload() directly.
|
|
||||||
// Instead, queue a follow-up user command that executes the command above.
|
|
||||||
pi.registerTool({
|
|
||||||
name: "reload_runtime",
|
|
||||||
label: "Reload Runtime",
|
|
||||||
description: "Reload extensions, skills, prompts, and themes",
|
|
||||||
parameters: Type.Object({}),
|
|
||||||
async execute() {
|
|
||||||
pi.sendUserMessage("/reload-runtime", { deliverAs: "followUp" });
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Queued /reload-runtime as a follow-up command." }],
|
|
||||||
details: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
/**
|
|
||||||
* RPC Extension UI Demo
|
|
||||||
*
|
|
||||||
* Purpose-built extension that exercises all RPC-supported extension UI methods.
|
|
||||||
* Designed to be loaded alongside the rpc-extension-ui-example.ts script to
|
|
||||||
* demonstrate the full extension UI protocol.
|
|
||||||
*
|
|
||||||
* UI methods exercised:
|
|
||||||
* - select() - on tool_call for dangerous bash commands
|
|
||||||
* - confirm() - on session_before_switch
|
|
||||||
* - input() - via /rpc-input command
|
|
||||||
* - editor() - via /rpc-editor command
|
|
||||||
* - notify() - after each dialog completes
|
|
||||||
* - setStatus() - on turn_start/turn_end
|
|
||||||
* - setWidget() - on session_start
|
|
||||||
* - setTitle() - on session_start and session_switch
|
|
||||||
* - setEditorText() - via /rpc-prefill command
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
let turnCount = 0;
|
|
||||||
|
|
||||||
// -- setTitle, setWidget, setStatus on session lifecycle --
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
ctx.ui.setTitle("pi RPC Demo");
|
|
||||||
ctx.ui.setWidget("rpc-demo", ["--- RPC Extension UI Demo ---", "Loaded and ready."]);
|
|
||||||
ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_switch", async (_event, ctx) => {
|
|
||||||
turnCount = 0;
|
|
||||||
ctx.ui.setTitle("pi RPC Demo (new session)");
|
|
||||||
ctx.ui.setStatus("rpc-demo", `Turns: ${turnCount}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- setStatus on turn lifecycle --
|
|
||||||
|
|
||||||
pi.on("turn_start", async (_event, ctx) => {
|
|
||||||
turnCount++;
|
|
||||||
ctx.ui.setStatus("rpc-demo", `Turn ${turnCount} running...`);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("turn_end", async (_event, ctx) => {
|
|
||||||
ctx.ui.setStatus("rpc-demo", `Turn ${turnCount} done`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- select on dangerous tool calls --
|
|
||||||
|
|
||||||
pi.on("tool_call", async (event, ctx) => {
|
|
||||||
if (event.toolName !== "bash") return undefined;
|
|
||||||
|
|
||||||
const command = event.input.command as string;
|
|
||||||
const isDangerous = /\brm\s+(-rf?|--recursive)/i.test(command) || /\bsudo\b/i.test(command);
|
|
||||||
|
|
||||||
if (isDangerous) {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
return { block: true, reason: "Dangerous command blocked (no UI)" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const choice = await ctx.ui.select(`Dangerous command: ${command}`, ["Allow", "Block"]);
|
|
||||||
if (choice !== "Allow") {
|
|
||||||
ctx.ui.notify("Command blocked by user", "warning");
|
|
||||||
return { block: true, reason: "Blocked by user" };
|
|
||||||
}
|
|
||||||
ctx.ui.notify("Command allowed", "info");
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- confirm on session clear --
|
|
||||||
|
|
||||||
pi.on("session_before_switch", async (event, ctx) => {
|
|
||||||
if (event.reason !== "new") return;
|
|
||||||
if (!ctx.hasUI) return;
|
|
||||||
|
|
||||||
const confirmed = await ctx.ui.confirm("Clear session?", "All messages will be lost.");
|
|
||||||
if (!confirmed) {
|
|
||||||
ctx.ui.notify("Clear cancelled", "info");
|
|
||||||
return { cancel: true };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- input via command --
|
|
||||||
|
|
||||||
pi.registerCommand("rpc-input", {
|
|
||||||
description: "Prompt for text input (demonstrates ctx.ui.input in RPC)",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const value = await ctx.ui.input("Enter a value", "type something...");
|
|
||||||
if (value) {
|
|
||||||
ctx.ui.notify(`You entered: ${value}`, "info");
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify("Input cancelled", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- editor via command --
|
|
||||||
|
|
||||||
pi.registerCommand("rpc-editor", {
|
|
||||||
description: "Open multi-line editor (demonstrates ctx.ui.editor in RPC)",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
const text = await ctx.ui.editor("Edit some text", "Line 1\nLine 2\nLine 3");
|
|
||||||
if (text) {
|
|
||||||
ctx.ui.notify(`Editor submitted (${text.split("\n").length} lines)`, "info");
|
|
||||||
} else {
|
|
||||||
ctx.ui.notify("Editor cancelled", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// -- setEditorText via command --
|
|
||||||
|
|
||||||
pi.registerCommand("rpc-prefill", {
|
|
||||||
description: "Prefill the input editor (demonstrates ctx.ui.setEditorText in RPC)",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
ctx.ui.setEditorText("This text was set by the rpc-demo extension.");
|
|
||||||
ctx.ui.notify("Editor prefilled", "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
node_modules
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
/**
|
|
||||||
* Sandbox Extension - OS-level sandboxing for bash commands
|
|
||||||
*
|
|
||||||
* Uses @anthropic-ai/sandbox-runtime to enforce filesystem and network
|
|
||||||
* restrictions on bash commands at the OS level (sandbox-exec on macOS,
|
|
||||||
* bubblewrap on Linux).
|
|
||||||
*
|
|
||||||
* Config files (merged, project takes precedence):
|
|
||||||
* - ~/.pi/agent/sandbox.json (global)
|
|
||||||
* - <cwd>/.pi/sandbox.json (project-local)
|
|
||||||
*
|
|
||||||
* Example .pi/sandbox.json:
|
|
||||||
* ```json
|
|
||||||
* {
|
|
||||||
* "enabled": true,
|
|
||||||
* "network": {
|
|
||||||
* "allowedDomains": ["github.com", "*.github.com"],
|
|
||||||
* "deniedDomains": []
|
|
||||||
* },
|
|
||||||
* "filesystem": {
|
|
||||||
* "denyRead": ["~/.ssh", "~/.aws"],
|
|
||||||
* "allowWrite": [".", "/tmp"],
|
|
||||||
* "denyWrite": [".env"]
|
|
||||||
* }
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* - `pi -e ./sandbox` - sandbox enabled with default/config settings
|
|
||||||
* - `pi -e ./sandbox --no-sandbox` - disable sandboxing
|
|
||||||
* - `/sandbox` - show current sandbox configuration
|
|
||||||
*
|
|
||||||
* Setup:
|
|
||||||
* 1. Copy sandbox/ directory to ~/.pi/agent/extensions/
|
|
||||||
* 2. Run `npm install` in ~/.pi/agent/extensions/sandbox/
|
|
||||||
*
|
|
||||||
* Linux also requires: bubblewrap, socat, ripgrep
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
|
||||||
import { homedir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import { SandboxManager, type SandboxRuntimeConfig } from "@anthropic-ai/sandbox-runtime";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { type BashOperations, createBashTool } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
interface SandboxConfig extends SandboxRuntimeConfig {
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG: SandboxConfig = {
|
|
||||||
enabled: true,
|
|
||||||
network: {
|
|
||||||
allowedDomains: [
|
|
||||||
"npmjs.org",
|
|
||||||
"*.npmjs.org",
|
|
||||||
"registry.npmjs.org",
|
|
||||||
"registry.yarnpkg.com",
|
|
||||||
"pypi.org",
|
|
||||||
"*.pypi.org",
|
|
||||||
"github.com",
|
|
||||||
"*.github.com",
|
|
||||||
"api.github.com",
|
|
||||||
"raw.githubusercontent.com",
|
|
||||||
],
|
|
||||||
deniedDomains: [],
|
|
||||||
},
|
|
||||||
filesystem: {
|
|
||||||
denyRead: ["~/.ssh", "~/.aws", "~/.gnupg"],
|
|
||||||
allowWrite: [".", "/tmp"],
|
|
||||||
denyWrite: [".env", ".env.*", "*.pem", "*.key"],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function loadConfig(cwd: string): SandboxConfig {
|
|
||||||
const projectConfigPath = join(cwd, ".pi", "sandbox.json");
|
|
||||||
const globalConfigPath = join(homedir(), ".pi", "agent", "sandbox.json");
|
|
||||||
|
|
||||||
let globalConfig: Partial<SandboxConfig> = {};
|
|
||||||
let projectConfig: Partial<SandboxConfig> = {};
|
|
||||||
|
|
||||||
if (existsSync(globalConfigPath)) {
|
|
||||||
try {
|
|
||||||
globalConfig = JSON.parse(readFileSync(globalConfigPath, "utf-8"));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Warning: Could not parse ${globalConfigPath}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existsSync(projectConfigPath)) {
|
|
||||||
try {
|
|
||||||
projectConfig = JSON.parse(readFileSync(projectConfigPath, "utf-8"));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Warning: Could not parse ${projectConfigPath}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deepMerge(deepMerge(DEFAULT_CONFIG, globalConfig), projectConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deepMerge(base: SandboxConfig, overrides: Partial<SandboxConfig>): SandboxConfig {
|
|
||||||
const result: SandboxConfig = { ...base };
|
|
||||||
|
|
||||||
if (overrides.enabled !== undefined) result.enabled = overrides.enabled;
|
|
||||||
if (overrides.network) {
|
|
||||||
result.network = { ...base.network, ...overrides.network };
|
|
||||||
}
|
|
||||||
if (overrides.filesystem) {
|
|
||||||
result.filesystem = { ...base.filesystem, ...overrides.filesystem };
|
|
||||||
}
|
|
||||||
|
|
||||||
const extOverrides = overrides as {
|
|
||||||
ignoreViolations?: Record<string, string[]>;
|
|
||||||
enableWeakerNestedSandbox?: boolean;
|
|
||||||
};
|
|
||||||
const extResult = result as { ignoreViolations?: Record<string, string[]>; enableWeakerNestedSandbox?: boolean };
|
|
||||||
|
|
||||||
if (extOverrides.ignoreViolations) {
|
|
||||||
extResult.ignoreViolations = extOverrides.ignoreViolations;
|
|
||||||
}
|
|
||||||
if (extOverrides.enableWeakerNestedSandbox !== undefined) {
|
|
||||||
extResult.enableWeakerNestedSandbox = extOverrides.enableWeakerNestedSandbox;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createSandboxedBashOps(): BashOperations {
|
|
||||||
return {
|
|
||||||
async exec(command, cwd, { onData, signal, timeout }) {
|
|
||||||
if (!existsSync(cwd)) {
|
|
||||||
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wrappedCommand = await SandboxManager.wrapWithSandbox(command);
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn("bash", ["-c", wrappedCommand], {
|
|
||||||
cwd,
|
|
||||||
detached: true,
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
|
||||||
|
|
||||||
let timedOut = false;
|
|
||||||
let timeoutHandle: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
if (timeout !== undefined && timeout > 0) {
|
|
||||||
timeoutHandle = setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
if (child.pid) {
|
|
||||||
try {
|
|
||||||
process.kill(-child.pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, timeout * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
child.stdout?.on("data", onData);
|
|
||||||
child.stderr?.on("data", onData);
|
|
||||||
|
|
||||||
child.on("error", (err) => {
|
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
const onAbort = () => {
|
|
||||||
if (child.pid) {
|
|
||||||
try {
|
|
||||||
process.kill(-child.pid, "SIGKILL");
|
|
||||||
} catch {
|
|
||||||
child.kill("SIGKILL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
|
|
||||||
if (signal?.aborted) {
|
|
||||||
reject(new Error("aborted"));
|
|
||||||
} else if (timedOut) {
|
|
||||||
reject(new Error(`timeout:${timeout}`));
|
|
||||||
} else {
|
|
||||||
resolve({ exitCode: code });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerFlag("no-sandbox", {
|
|
||||||
description: "Disable OS-level sandboxing for bash commands",
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const localCwd = process.cwd();
|
|
||||||
const localBash = createBashTool(localCwd);
|
|
||||||
|
|
||||||
let sandboxEnabled = false;
|
|
||||||
let sandboxInitialized = false;
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...localBash,
|
|
||||||
label: "bash (sandboxed)",
|
|
||||||
async execute(id, params, signal, onUpdate, _ctx) {
|
|
||||||
if (!sandboxEnabled || !sandboxInitialized) {
|
|
||||||
return localBash.execute(id, params, signal, onUpdate);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sandboxedBash = createBashTool(localCwd, {
|
|
||||||
operations: createSandboxedBashOps(),
|
|
||||||
});
|
|
||||||
return sandboxedBash.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("user_bash", () => {
|
|
||||||
if (!sandboxEnabled || !sandboxInitialized) return;
|
|
||||||
return { operations: createSandboxedBashOps() };
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
const noSandbox = pi.getFlag("no-sandbox") as boolean;
|
|
||||||
|
|
||||||
if (noSandbox) {
|
|
||||||
sandboxEnabled = false;
|
|
||||||
ctx.ui.notify("Sandbox disabled via --no-sandbox", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig(ctx.cwd);
|
|
||||||
|
|
||||||
if (!config.enabled) {
|
|
||||||
sandboxEnabled = false;
|
|
||||||
ctx.ui.notify("Sandbox disabled via config", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const platform = process.platform;
|
|
||||||
if (platform !== "darwin" && platform !== "linux") {
|
|
||||||
sandboxEnabled = false;
|
|
||||||
ctx.ui.notify(`Sandbox not supported on ${platform}`, "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const configExt = config as unknown as {
|
|
||||||
ignoreViolations?: Record<string, string[]>;
|
|
||||||
enableWeakerNestedSandbox?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
await SandboxManager.initialize({
|
|
||||||
network: config.network,
|
|
||||||
filesystem: config.filesystem,
|
|
||||||
ignoreViolations: configExt.ignoreViolations,
|
|
||||||
enableWeakerNestedSandbox: configExt.enableWeakerNestedSandbox,
|
|
||||||
});
|
|
||||||
|
|
||||||
sandboxEnabled = true;
|
|
||||||
sandboxInitialized = true;
|
|
||||||
|
|
||||||
const networkCount = config.network?.allowedDomains?.length ?? 0;
|
|
||||||
const writeCount = config.filesystem?.allowWrite?.length ?? 0;
|
|
||||||
ctx.ui.setStatus(
|
|
||||||
"sandbox",
|
|
||||||
ctx.ui.theme.fg("accent", `🔒 Sandbox: ${networkCount} domains, ${writeCount} write paths`),
|
|
||||||
);
|
|
||||||
ctx.ui.notify("Sandbox initialized", "info");
|
|
||||||
} catch (err) {
|
|
||||||
sandboxEnabled = false;
|
|
||||||
ctx.ui.notify(`Sandbox initialization failed: ${err instanceof Error ? err.message : err}`, "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_shutdown", async () => {
|
|
||||||
if (sandboxInitialized) {
|
|
||||||
try {
|
|
||||||
await SandboxManager.reset();
|
|
||||||
} catch {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerCommand("sandbox", {
|
|
||||||
description: "Show sandbox configuration",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (!sandboxEnabled) {
|
|
||||||
ctx.ui.notify("Sandbox is disabled", "info");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = loadConfig(ctx.cwd);
|
|
||||||
const lines = [
|
|
||||||
"Sandbox Configuration:",
|
|
||||||
"",
|
|
||||||
"Network:",
|
|
||||||
` Allowed: ${config.network?.allowedDomains?.join(", ") || "(none)"}`,
|
|
||||||
` Denied: ${config.network?.deniedDomains?.join(", ") || "(none)"}`,
|
|
||||||
"",
|
|
||||||
"Filesystem:",
|
|
||||||
` Deny Read: ${config.filesystem?.denyRead?.join(", ") || "(none)"}`,
|
|
||||||
` Allow Write: ${config.filesystem?.allowWrite?.join(", ") || "(none)"}`,
|
|
||||||
` Deny Write: ${config.filesystem?.denyWrite?.join(", ") || "(none)"}`,
|
|
||||||
];
|
|
||||||
ctx.ui.notify(lines.join("\n"), "info");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-sandbox",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"name": "pi-extension-sandbox",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@anthropic-ai/sandbox-runtime": {
|
|
||||||
"version": "0.0.26",
|
|
||||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sandbox-runtime/-/sandbox-runtime-0.0.26.tgz",
|
|
||||||
"integrity": "sha512-DYV5LSsVMnzq0lbfaYMSpxZPUMAx4+hy343dRss+pVCLIfF62qOhxpYfZ5TmOk1GTDQm5f9wPprMNSStmnsV4w==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"@pondwader/socks5-server": "^1.0.10",
|
|
||||||
"@types/lodash-es": "^4.17.12",
|
|
||||||
"commander": "^12.1.0",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"shell-quote": "^1.8.3",
|
|
||||||
"zod": "^3.24.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"srt": "dist/cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@pondwader/socks5-server": {
|
|
||||||
"version": "1.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@pondwader/socks5-server/-/socks5-server-1.0.10.tgz",
|
|
||||||
"integrity": "sha512-bQY06wzzR8D2+vVCUoBsr5QS2U6UgPUQRmErNwtsuI6vLcyRKkafjkr3KxbtGFf9aBBIV2mcvlsKD1UYaIV+sg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/lodash": {
|
|
||||||
"version": "4.17.23",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.23.tgz",
|
|
||||||
"integrity": "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/lodash-es": {
|
|
||||||
"version": "4.17.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/lodash": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "12.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
|
||||||
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lodash-es": {
|
|
||||||
"version": "4.17.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.22.tgz",
|
|
||||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shell-quote": {
|
|
||||||
"version": "1.8.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
|
||||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "3.25.76",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"name": "pi-extension-sandbox",
|
|
||||||
"private": true,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"clean": "echo 'nothing to clean'",
|
|
||||||
"build": "echo 'nothing to build'",
|
|
||||||
"check": "echo 'nothing to check'"
|
|
||||||
},
|
|
||||||
"pi": {
|
|
||||||
"extensions": [
|
|
||||||
"./index.ts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@anthropic-ai/sandbox-runtime": "^0.0.26"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
/**
|
|
||||||
* Send User Message Example
|
|
||||||
*
|
|
||||||
* Demonstrates pi.sendUserMessage() for sending user messages from extensions.
|
|
||||||
* Unlike pi.sendMessage() which sends custom messages, sendUserMessage() sends
|
|
||||||
* actual user messages that appear in the conversation as if typed by the user.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* /ask What is 2+2? - Sends a user message (always triggers a turn)
|
|
||||||
* /steer Focus on X - Sends while streaming with steer delivery
|
|
||||||
* /followup And then? - Sends while streaming with followUp delivery
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Simple command that sends a user message
|
|
||||||
pi.registerCommand("ask", {
|
|
||||||
description: "Send a user message to the agent",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!args.trim()) {
|
|
||||||
ctx.ui.notify("Usage: /ask <message>", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendUserMessage always triggers a turn when not streaming
|
|
||||||
// If streaming, it will throw (no deliverAs specified)
|
|
||||||
if (!ctx.isIdle()) {
|
|
||||||
ctx.ui.notify("Agent is busy. Use /steer or /followup instead.", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pi.sendUserMessage(args);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Command that steers the agent mid-conversation
|
|
||||||
pi.registerCommand("steer", {
|
|
||||||
description: "Send a steering message (interrupts current processing)",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!args.trim()) {
|
|
||||||
ctx.ui.notify("Usage: /steer <message>", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.isIdle()) {
|
|
||||||
// Not streaming, just send normally
|
|
||||||
pi.sendUserMessage(args);
|
|
||||||
} else {
|
|
||||||
// Streaming - use steer to interrupt
|
|
||||||
pi.sendUserMessage(args, { deliverAs: "steer" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Command that queues a follow-up message
|
|
||||||
pi.registerCommand("followup", {
|
|
||||||
description: "Queue a follow-up message (waits for current processing)",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!args.trim()) {
|
|
||||||
ctx.ui.notify("Usage: /followup <message>", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.isIdle()) {
|
|
||||||
// Not streaming, just send normally
|
|
||||||
pi.sendUserMessage(args);
|
|
||||||
} else {
|
|
||||||
// Streaming - queue as follow-up
|
|
||||||
pi.sendUserMessage(args, { deliverAs: "followUp" });
|
|
||||||
ctx.ui.notify("Follow-up queued", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Example with content array (text + images would go here)
|
|
||||||
pi.registerCommand("askwith", {
|
|
||||||
description: "Send a user message with structured content",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
if (!args.trim()) {
|
|
||||||
ctx.ui.notify("Usage: /askwith <message>", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ctx.isIdle()) {
|
|
||||||
ctx.ui.notify("Agent is busy", "warning");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendUserMessage accepts string or (TextContent | ImageContent)[]
|
|
||||||
pi.sendUserMessage([
|
|
||||||
{ type: "text", text: `User request: ${args}` },
|
|
||||||
{ type: "text", text: "Please respond concisely." },
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Session naming example.
|
|
||||||
*
|
|
||||||
* Shows setSessionName/getSessionName to give sessions friendly names
|
|
||||||
* that appear in the session selector instead of the first message.
|
|
||||||
*
|
|
||||||
* Usage: /session-name [name] - set or show session name
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("session-name", {
|
|
||||||
description: "Set or show session name (usage: /session-name [new name])",
|
|
||||||
handler: async (args, ctx) => {
|
|
||||||
const name = args.trim();
|
|
||||||
|
|
||||||
if (name) {
|
|
||||||
pi.setSessionName(name);
|
|
||||||
ctx.ui.notify(`Session named: ${name}`, "info");
|
|
||||||
} else {
|
|
||||||
const current = pi.getSessionName();
|
|
||||||
ctx.ui.notify(current ? `Session: ${current}` : "No session name set", "info");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
/**
|
|
||||||
* Shutdown Command Extension
|
|
||||||
*
|
|
||||||
* Adds a /quit command that allows extensions to trigger clean shutdown.
|
|
||||||
* Demonstrates how extensions can use ctx.shutdown() to exit pi cleanly.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
// Register a /quit command that cleanly exits pi
|
|
||||||
pi.registerCommand("quit", {
|
|
||||||
description: "Exit pi cleanly",
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
ctx.shutdown();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// You can also create a tool that shuts down after completing work
|
|
||||||
pi.registerTool({
|
|
||||||
name: "finish_and_exit",
|
|
||||||
label: "Finish and Exit",
|
|
||||||
description: "Complete a task and exit pi",
|
|
||||||
parameters: Type.Object({}),
|
|
||||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
||||||
// Do any final work here...
|
|
||||||
// Request graceful shutdown (deferred until agent is idle)
|
|
||||||
ctx.shutdown();
|
|
||||||
|
|
||||||
// This return is sent to the LLM before shutdown occurs
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Shutdown requested. Exiting after this response." }],
|
|
||||||
details: {},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// You could also create a more complex tool with parameters
|
|
||||||
pi.registerTool({
|
|
||||||
name: "deploy_and_exit",
|
|
||||||
label: "Deploy and Exit",
|
|
||||||
description: "Deploy the application and exit pi",
|
|
||||||
parameters: Type.Object({
|
|
||||||
environment: Type.String({ description: "Target environment (e.g., production, staging)" }),
|
|
||||||
}),
|
|
||||||
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
|
|
||||||
onUpdate?.({ content: [{ type: "text", text: `Deploying to ${params.environment}...` }], details: {} });
|
|
||||||
|
|
||||||
// Example deployment logic
|
|
||||||
// const result = await pi.exec("npm", ["run", "deploy", params.environment], { signal });
|
|
||||||
|
|
||||||
// On success, request graceful shutdown
|
|
||||||
onUpdate?.({ content: [{ type: "text", text: "Deployment complete, exiting..." }], details: {} });
|
|
||||||
ctx.shutdown();
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Done! Shutdown requested." }],
|
|
||||||
details: { environment: params.environment },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,343 +0,0 @@
|
||||||
/**
|
|
||||||
* Snake game extension - play snake with /snake command
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
const GAME_WIDTH = 40;
|
|
||||||
const GAME_HEIGHT = 15;
|
|
||||||
const TICK_MS = 100;
|
|
||||||
|
|
||||||
type Direction = "up" | "down" | "left" | "right";
|
|
||||||
type Point = { x: number; y: number };
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
snake: Point[];
|
|
||||||
food: Point;
|
|
||||||
direction: Direction;
|
|
||||||
nextDirection: Direction;
|
|
||||||
score: number;
|
|
||||||
gameOver: boolean;
|
|
||||||
highScore: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInitialState(): GameState {
|
|
||||||
const startX = Math.floor(GAME_WIDTH / 2);
|
|
||||||
const startY = Math.floor(GAME_HEIGHT / 2);
|
|
||||||
return {
|
|
||||||
snake: [
|
|
||||||
{ x: startX, y: startY },
|
|
||||||
{ x: startX - 1, y: startY },
|
|
||||||
{ x: startX - 2, y: startY },
|
|
||||||
],
|
|
||||||
food: spawnFood([{ x: startX, y: startY }]),
|
|
||||||
direction: "right",
|
|
||||||
nextDirection: "right",
|
|
||||||
score: 0,
|
|
||||||
gameOver: false,
|
|
||||||
highScore: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnFood(snake: Point[]): Point {
|
|
||||||
let food: Point;
|
|
||||||
do {
|
|
||||||
food = {
|
|
||||||
x: Math.floor(Math.random() * GAME_WIDTH),
|
|
||||||
y: Math.floor(Math.random() * GAME_HEIGHT),
|
|
||||||
};
|
|
||||||
} while (snake.some((s) => s.x === food.x && s.y === food.y));
|
|
||||||
return food;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SnakeComponent {
|
|
||||||
private state: GameState;
|
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private onClose: () => void;
|
|
||||||
private onSave: (state: GameState | null) => void;
|
|
||||||
private tui: { requestRender: () => void };
|
|
||||||
private cachedLines: string[] = [];
|
|
||||||
private cachedWidth = 0;
|
|
||||||
private version = 0;
|
|
||||||
private cachedVersion = -1;
|
|
||||||
private paused: boolean;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
tui: { requestRender: () => void },
|
|
||||||
onClose: () => void,
|
|
||||||
onSave: (state: GameState | null) => void,
|
|
||||||
savedState?: GameState,
|
|
||||||
) {
|
|
||||||
this.tui = tui;
|
|
||||||
if (savedState && !savedState.gameOver) {
|
|
||||||
// Resume from saved state, start paused
|
|
||||||
this.state = savedState;
|
|
||||||
this.paused = true;
|
|
||||||
} else {
|
|
||||||
// New game or saved game was over
|
|
||||||
this.state = createInitialState();
|
|
||||||
if (savedState) {
|
|
||||||
this.state.highScore = savedState.highScore;
|
|
||||||
}
|
|
||||||
this.paused = false;
|
|
||||||
this.startGame();
|
|
||||||
}
|
|
||||||
this.onClose = onClose;
|
|
||||||
this.onSave = onSave;
|
|
||||||
}
|
|
||||||
|
|
||||||
private startGame(): void {
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
if (!this.state.gameOver) {
|
|
||||||
this.tick();
|
|
||||||
this.version++;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}, TICK_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private tick(): void {
|
|
||||||
// Apply queued direction change
|
|
||||||
this.state.direction = this.state.nextDirection;
|
|
||||||
|
|
||||||
// Calculate new head position
|
|
||||||
const head = this.state.snake[0];
|
|
||||||
let newHead: Point;
|
|
||||||
|
|
||||||
switch (this.state.direction) {
|
|
||||||
case "up":
|
|
||||||
newHead = { x: head.x, y: head.y - 1 };
|
|
||||||
break;
|
|
||||||
case "down":
|
|
||||||
newHead = { x: head.x, y: head.y + 1 };
|
|
||||||
break;
|
|
||||||
case "left":
|
|
||||||
newHead = { x: head.x - 1, y: head.y };
|
|
||||||
break;
|
|
||||||
case "right":
|
|
||||||
newHead = { x: head.x + 1, y: head.y };
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check wall collision
|
|
||||||
if (newHead.x < 0 || newHead.x >= GAME_WIDTH || newHead.y < 0 || newHead.y >= GAME_HEIGHT) {
|
|
||||||
this.state.gameOver = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check self collision
|
|
||||||
if (this.state.snake.some((s) => s.x === newHead.x && s.y === newHead.y)) {
|
|
||||||
this.state.gameOver = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move snake
|
|
||||||
this.state.snake.unshift(newHead);
|
|
||||||
|
|
||||||
// Check food collision
|
|
||||||
if (newHead.x === this.state.food.x && newHead.y === this.state.food.y) {
|
|
||||||
this.state.score += 10;
|
|
||||||
if (this.state.score > this.state.highScore) {
|
|
||||||
this.state.highScore = this.state.score;
|
|
||||||
}
|
|
||||||
this.state.food = spawnFood(this.state.snake);
|
|
||||||
} else {
|
|
||||||
this.state.snake.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
// If paused (resuming), wait for any key
|
|
||||||
if (this.paused) {
|
|
||||||
if (matchesKey(data, "escape") || data === "q" || data === "Q") {
|
|
||||||
// Quit without clearing save
|
|
||||||
this.dispose();
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Any other key resumes
|
|
||||||
this.paused = false;
|
|
||||||
this.startGame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC to pause and save
|
|
||||||
if (matchesKey(data, "escape")) {
|
|
||||||
this.dispose();
|
|
||||||
this.onSave(this.state);
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Q to quit without saving (clears saved state)
|
|
||||||
if (data === "q" || data === "Q") {
|
|
||||||
this.dispose();
|
|
||||||
this.onSave(null); // Clear saved state
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow keys or WASD
|
|
||||||
if (matchesKey(data, "up") || data === "w" || data === "W") {
|
|
||||||
if (this.state.direction !== "down") this.state.nextDirection = "up";
|
|
||||||
} else if (matchesKey(data, "down") || data === "s" || data === "S") {
|
|
||||||
if (this.state.direction !== "up") this.state.nextDirection = "down";
|
|
||||||
} else if (matchesKey(data, "right") || data === "d" || data === "D") {
|
|
||||||
if (this.state.direction !== "left") this.state.nextDirection = "right";
|
|
||||||
} else if (matchesKey(data, "left") || data === "a" || data === "A") {
|
|
||||||
if (this.state.direction !== "right") this.state.nextDirection = "left";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart on game over
|
|
||||||
if (this.state.gameOver && (data === "r" || data === "R" || data === " ")) {
|
|
||||||
const highScore = this.state.highScore;
|
|
||||||
this.state = createInitialState();
|
|
||||||
this.state.highScore = highScore;
|
|
||||||
this.onSave(null); // Clear saved state on restart
|
|
||||||
this.version++;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(): void {
|
|
||||||
this.cachedWidth = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
|
||||||
return this.cachedLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Each game cell is 2 chars wide to appear square (terminal cells are ~2:1 aspect)
|
|
||||||
const cellWidth = 2;
|
|
||||||
const effectiveWidth = Math.min(GAME_WIDTH, Math.floor((width - 4) / cellWidth));
|
|
||||||
const effectiveHeight = GAME_HEIGHT;
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
||||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
||||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
||||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
||||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
||||||
|
|
||||||
const boxWidth = effectiveWidth * cellWidth;
|
|
||||||
|
|
||||||
// Helper to pad content inside box
|
|
||||||
const boxLine = (content: string) => {
|
|
||||||
const contentLen = visibleWidth(content);
|
|
||||||
const padding = Math.max(0, boxWidth - contentLen);
|
|
||||||
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Top border
|
|
||||||
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
|
||||||
|
|
||||||
// Header with score
|
|
||||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
|
||||||
const highText = `High: ${bold(yellow(String(this.state.highScore)))}`;
|
|
||||||
const title = `${bold(green("SNAKE"))} │ ${scoreText} │ ${highText}`;
|
|
||||||
lines.push(this.padLine(boxLine(title), width));
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
||||||
|
|
||||||
// Game grid
|
|
||||||
for (let y = 0; y < effectiveHeight; y++) {
|
|
||||||
let row = "";
|
|
||||||
for (let x = 0; x < effectiveWidth; x++) {
|
|
||||||
const isHead = this.state.snake[0].x === x && this.state.snake[0].y === y;
|
|
||||||
const isBody = this.state.snake.slice(1).some((s) => s.x === x && s.y === y);
|
|
||||||
const isFood = this.state.food.x === x && this.state.food.y === y;
|
|
||||||
|
|
||||||
if (isHead) {
|
|
||||||
row += green("██"); // Snake head (2 chars)
|
|
||||||
} else if (isBody) {
|
|
||||||
row += green("▓▓"); // Snake body (2 chars)
|
|
||||||
} else if (isFood) {
|
|
||||||
row += red("◆ "); // Food (2 chars)
|
|
||||||
} else {
|
|
||||||
row += " "; // Empty cell (2 spaces)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
let footer: string;
|
|
||||||
if (this.paused) {
|
|
||||||
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
|
||||||
} else if (this.state.gameOver) {
|
|
||||||
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
|
||||||
} else {
|
|
||||||
footer = `↑↓←→ or WASD to move, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
|
||||||
}
|
|
||||||
lines.push(this.padLine(boxLine(footer), width));
|
|
||||||
|
|
||||||
// Bottom border
|
|
||||||
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
|
||||||
|
|
||||||
this.cachedLines = lines;
|
|
||||||
this.cachedWidth = width;
|
|
||||||
this.cachedVersion = this.version;
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
private padLine(line: string, width: number): string {
|
|
||||||
// Calculate visible length (strip ANSI codes)
|
|
||||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
||||||
const padding = Math.max(0, width - visibleLen);
|
|
||||||
return line + " ".repeat(padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const SNAKE_SAVE_TYPE = "snake-save";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("snake", {
|
|
||||||
description: "Play Snake!",
|
|
||||||
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
ctx.ui.notify("Snake requires interactive mode", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved state from session
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
let savedState: GameState | undefined;
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
|
|
||||||
savedState = entry.data as GameState;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
|
||||||
return new SnakeComponent(
|
|
||||||
tui,
|
|
||||||
() => done(undefined),
|
|
||||||
(state) => {
|
|
||||||
// Save or clear state
|
|
||||||
pi.appendEntry(SNAKE_SAVE_TYPE, state);
|
|
||||||
},
|
|
||||||
savedState,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,560 +0,0 @@
|
||||||
/**
|
|
||||||
* Space Invaders game extension - play with /invaders command
|
|
||||||
* Uses Kitty keyboard protocol for smooth movement (press/release detection)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { isKeyRelease, Key, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
|
|
||||||
|
|
||||||
const GAME_WIDTH = 60;
|
|
||||||
const GAME_HEIGHT = 24;
|
|
||||||
const TICK_MS = 50;
|
|
||||||
const PLAYER_Y = GAME_HEIGHT - 2;
|
|
||||||
const ALIEN_ROWS = 5;
|
|
||||||
const ALIEN_COLS = 11;
|
|
||||||
const ALIEN_START_Y = 2;
|
|
||||||
|
|
||||||
type Point = { x: number; y: number };
|
|
||||||
|
|
||||||
interface Bullet extends Point {
|
|
||||||
direction: -1 | 1; // -1 = up (player), 1 = down (alien)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Alien extends Point {
|
|
||||||
type: number; // 0, 1, 2 for different alien types
|
|
||||||
alive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Shield {
|
|
||||||
x: number;
|
|
||||||
segments: boolean[][]; // 4x3 grid of destructible segments
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
player: { x: number; lives: number };
|
|
||||||
aliens: Alien[];
|
|
||||||
alienDirection: 1 | -1;
|
|
||||||
alienMoveCounter: number;
|
|
||||||
alienMoveDelay: number;
|
|
||||||
alienDropping: boolean;
|
|
||||||
bullets: Bullet[];
|
|
||||||
shields: Shield[];
|
|
||||||
score: number;
|
|
||||||
highScore: number;
|
|
||||||
level: number;
|
|
||||||
gameOver: boolean;
|
|
||||||
victory: boolean;
|
|
||||||
alienShootCounter: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyState {
|
|
||||||
left: boolean;
|
|
||||||
right: boolean;
|
|
||||||
fire: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createShields(): Shield[] {
|
|
||||||
const shields: Shield[] = [];
|
|
||||||
const shieldPositions = [8, 22, 36, 50];
|
|
||||||
for (const x of shieldPositions) {
|
|
||||||
shields.push({
|
|
||||||
x,
|
|
||||||
segments: [
|
|
||||||
[true, true, true, true],
|
|
||||||
[true, true, true, true],
|
|
||||||
[true, false, false, true],
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return shields;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAliens(): Alien[] {
|
|
||||||
const aliens: Alien[] = [];
|
|
||||||
for (let row = 0; row < ALIEN_ROWS; row++) {
|
|
||||||
const type = row === 0 ? 2 : row < 3 ? 1 : 0;
|
|
||||||
for (let col = 0; col < ALIEN_COLS; col++) {
|
|
||||||
aliens.push({
|
|
||||||
x: 4 + col * 5,
|
|
||||||
y: ALIEN_START_Y + row * 2,
|
|
||||||
type,
|
|
||||||
alive: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return aliens;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createInitialState(highScore = 0, level = 1): GameState {
|
|
||||||
return {
|
|
||||||
player: { x: Math.floor(GAME_WIDTH / 2), lives: 3 },
|
|
||||||
aliens: createAliens(),
|
|
||||||
alienDirection: 1,
|
|
||||||
alienMoveCounter: 0,
|
|
||||||
alienMoveDelay: Math.max(5, 20 - level * 2),
|
|
||||||
alienDropping: false,
|
|
||||||
bullets: [],
|
|
||||||
shields: createShields(),
|
|
||||||
score: 0,
|
|
||||||
highScore,
|
|
||||||
level,
|
|
||||||
gameOver: false,
|
|
||||||
victory: false,
|
|
||||||
alienShootCounter: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpaceInvadersComponent {
|
|
||||||
private state: GameState;
|
|
||||||
private keys: KeyState = { left: false, right: false, fire: false };
|
|
||||||
private interval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private onClose: () => void;
|
|
||||||
private onSave: (state: GameState | null) => void;
|
|
||||||
private tui: { requestRender: () => void };
|
|
||||||
private cachedLines: string[] = [];
|
|
||||||
private cachedWidth = 0;
|
|
||||||
private version = 0;
|
|
||||||
private cachedVersion = -1;
|
|
||||||
private paused: boolean;
|
|
||||||
private fireCooldown = 0;
|
|
||||||
private playerMoveCounter = 0;
|
|
||||||
|
|
||||||
// Opt-in to key release events for smooth movement
|
|
||||||
wantsKeyRelease = true;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
tui: { requestRender: () => void },
|
|
||||||
onClose: () => void,
|
|
||||||
onSave: (state: GameState | null) => void,
|
|
||||||
savedState?: GameState,
|
|
||||||
) {
|
|
||||||
this.tui = tui;
|
|
||||||
if (savedState && !savedState.gameOver && !savedState.victory) {
|
|
||||||
this.state = savedState;
|
|
||||||
this.paused = true;
|
|
||||||
} else {
|
|
||||||
this.state = createInitialState(savedState?.highScore);
|
|
||||||
this.paused = false;
|
|
||||||
this.startGame();
|
|
||||||
}
|
|
||||||
this.onClose = onClose;
|
|
||||||
this.onSave = onSave;
|
|
||||||
}
|
|
||||||
|
|
||||||
private startGame(): void {
|
|
||||||
this.interval = setInterval(() => {
|
|
||||||
if (!this.state.gameOver && !this.state.victory) {
|
|
||||||
this.tick();
|
|
||||||
this.version++;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}, TICK_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private tick(): void {
|
|
||||||
// Player movement (smooth, every other tick)
|
|
||||||
this.playerMoveCounter++;
|
|
||||||
if (this.playerMoveCounter >= 2) {
|
|
||||||
this.playerMoveCounter = 0;
|
|
||||||
if (this.keys.left && this.state.player.x > 2) {
|
|
||||||
this.state.player.x--;
|
|
||||||
}
|
|
||||||
if (this.keys.right && this.state.player.x < GAME_WIDTH - 3) {
|
|
||||||
this.state.player.x++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire cooldown
|
|
||||||
if (this.fireCooldown > 0) this.fireCooldown--;
|
|
||||||
|
|
||||||
// Player shooting
|
|
||||||
if (this.keys.fire && this.fireCooldown === 0) {
|
|
||||||
const playerBullets = this.state.bullets.filter((b) => b.direction === -1);
|
|
||||||
if (playerBullets.length < 2) {
|
|
||||||
this.state.bullets.push({ x: this.state.player.x, y: PLAYER_Y - 1, direction: -1 });
|
|
||||||
this.fireCooldown = 8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move bullets
|
|
||||||
this.state.bullets = this.state.bullets.filter((bullet) => {
|
|
||||||
bullet.y += bullet.direction;
|
|
||||||
return bullet.y >= 0 && bullet.y < GAME_HEIGHT;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Alien movement
|
|
||||||
this.state.alienMoveCounter++;
|
|
||||||
if (this.state.alienMoveCounter >= this.state.alienMoveDelay) {
|
|
||||||
this.state.alienMoveCounter = 0;
|
|
||||||
this.moveAliens();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alien shooting
|
|
||||||
this.state.alienShootCounter++;
|
|
||||||
if (this.state.alienShootCounter >= 30) {
|
|
||||||
this.state.alienShootCounter = 0;
|
|
||||||
this.alienShoot();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collision detection
|
|
||||||
this.checkCollisions();
|
|
||||||
|
|
||||||
// Check victory
|
|
||||||
if (this.state.aliens.every((a) => !a.alive)) {
|
|
||||||
this.state.victory = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private moveAliens(): void {
|
|
||||||
const aliveAliens = this.state.aliens.filter((a) => a.alive);
|
|
||||||
if (aliveAliens.length === 0) return;
|
|
||||||
|
|
||||||
if (this.state.alienDropping) {
|
|
||||||
// Drop down
|
|
||||||
for (const alien of aliveAliens) {
|
|
||||||
alien.y++;
|
|
||||||
if (alien.y >= PLAYER_Y - 1) {
|
|
||||||
this.state.gameOver = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.state.alienDropping = false;
|
|
||||||
} else {
|
|
||||||
// Check if we need to change direction
|
|
||||||
const minX = Math.min(...aliveAliens.map((a) => a.x));
|
|
||||||
const maxX = Math.max(...aliveAliens.map((a) => a.x));
|
|
||||||
|
|
||||||
if (
|
|
||||||
(this.state.alienDirection === 1 && maxX >= GAME_WIDTH - 3) ||
|
|
||||||
(this.state.alienDirection === -1 && minX <= 2)
|
|
||||||
) {
|
|
||||||
this.state.alienDirection *= -1;
|
|
||||||
this.state.alienDropping = true;
|
|
||||||
} else {
|
|
||||||
// Move horizontally
|
|
||||||
for (const alien of aliveAliens) {
|
|
||||||
alien.x += this.state.alienDirection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Speed up as fewer aliens remain
|
|
||||||
const aliveCount = aliveAliens.length;
|
|
||||||
if (aliveCount <= 5) {
|
|
||||||
this.state.alienMoveDelay = 1;
|
|
||||||
} else if (aliveCount <= 10) {
|
|
||||||
this.state.alienMoveDelay = 2;
|
|
||||||
} else if (aliveCount <= 20) {
|
|
||||||
this.state.alienMoveDelay = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private alienShoot(): void {
|
|
||||||
const aliveAliens = this.state.aliens.filter((a) => a.alive);
|
|
||||||
if (aliveAliens.length === 0) return;
|
|
||||||
|
|
||||||
// Find bottom-most alien in each column
|
|
||||||
const columns = new Map<number, Alien>();
|
|
||||||
for (const alien of aliveAliens) {
|
|
||||||
const existing = columns.get(alien.x);
|
|
||||||
if (!existing || alien.y > existing.y) {
|
|
||||||
columns.set(alien.x, alien);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Random column shoots
|
|
||||||
const shooters = Array.from(columns.values());
|
|
||||||
if (shooters.length > 0 && this.state.bullets.filter((b) => b.direction === 1).length < 3) {
|
|
||||||
const shooter = shooters[Math.floor(Math.random() * shooters.length)];
|
|
||||||
this.state.bullets.push({ x: shooter.x, y: shooter.y + 1, direction: 1 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkCollisions(): void {
|
|
||||||
const bulletsToRemove = new Set<Bullet>();
|
|
||||||
|
|
||||||
for (const bullet of this.state.bullets) {
|
|
||||||
// Player bullets hitting aliens
|
|
||||||
if (bullet.direction === -1) {
|
|
||||||
for (const alien of this.state.aliens) {
|
|
||||||
if (alien.alive && Math.abs(bullet.x - alien.x) <= 1 && bullet.y === alien.y) {
|
|
||||||
alien.alive = false;
|
|
||||||
bulletsToRemove.add(bullet);
|
|
||||||
const points = [10, 20, 30][alien.type];
|
|
||||||
this.state.score += points;
|
|
||||||
if (this.state.score > this.state.highScore) {
|
|
||||||
this.state.highScore = this.state.score;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Alien bullets hitting player
|
|
||||||
if (bullet.direction === 1) {
|
|
||||||
if (Math.abs(bullet.x - this.state.player.x) <= 1 && bullet.y === PLAYER_Y) {
|
|
||||||
bulletsToRemove.add(bullet);
|
|
||||||
this.state.player.lives--;
|
|
||||||
if (this.state.player.lives <= 0) {
|
|
||||||
this.state.gameOver = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bullets hitting shields
|
|
||||||
for (const shield of this.state.shields) {
|
|
||||||
const relX = bullet.x - shield.x;
|
|
||||||
const relY = bullet.y - (PLAYER_Y - 5);
|
|
||||||
if (relX >= 0 && relX < 4 && relY >= 0 && relY < 3) {
|
|
||||||
if (shield.segments[relY][relX]) {
|
|
||||||
shield.segments[relY][relX] = false;
|
|
||||||
bulletsToRemove.add(bullet);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.bullets = this.state.bullets.filter((b) => !bulletsToRemove.has(b));
|
|
||||||
}
|
|
||||||
|
|
||||||
handleInput(data: string): void {
|
|
||||||
const released = isKeyRelease(data);
|
|
||||||
|
|
||||||
// Pause handling
|
|
||||||
if (this.paused && !released) {
|
|
||||||
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
||||||
this.dispose();
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.paused = false;
|
|
||||||
this.startGame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC to pause and save
|
|
||||||
if (!released && matchesKey(data, Key.escape)) {
|
|
||||||
this.dispose();
|
|
||||||
this.onSave(this.state);
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Q to quit without saving
|
|
||||||
if (!released && (data === "q" || data === "Q")) {
|
|
||||||
this.dispose();
|
|
||||||
this.onSave(null);
|
|
||||||
this.onClose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Movement keys (track press/release state)
|
|
||||||
if (matchesKey(data, Key.left) || data === "a" || data === "A" || matchesKey(data, "a")) {
|
|
||||||
this.keys.left = !released;
|
|
||||||
}
|
|
||||||
if (matchesKey(data, Key.right) || data === "d" || data === "D" || matchesKey(data, "d")) {
|
|
||||||
this.keys.right = !released;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fire key
|
|
||||||
if (matchesKey(data, Key.space) || data === " " || data === "f" || data === "F" || matchesKey(data, "f")) {
|
|
||||||
this.keys.fire = !released;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart on game over or victory
|
|
||||||
if (!released && (this.state.gameOver || this.state.victory)) {
|
|
||||||
if (data === "r" || data === "R" || data === " ") {
|
|
||||||
const highScore = this.state.highScore;
|
|
||||||
const nextLevel = this.state.victory ? this.state.level + 1 : 1;
|
|
||||||
this.state = createInitialState(highScore, nextLevel);
|
|
||||||
this.keys = { left: false, right: false, fire: false };
|
|
||||||
this.onSave(null);
|
|
||||||
this.version++;
|
|
||||||
this.tui.requestRender();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
invalidate(): void {
|
|
||||||
this.cachedWidth = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
render(width: number): string[] {
|
|
||||||
if (width === this.cachedWidth && this.cachedVersion === this.version) {
|
|
||||||
return this.cachedLines;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Colors
|
|
||||||
const dim = (s: string) => `\x1b[2m${s}\x1b[22m`;
|
|
||||||
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
|
|
||||||
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
|
|
||||||
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
|
|
||||||
const cyan = (s: string) => `\x1b[36m${s}\x1b[0m`;
|
|
||||||
const magenta = (s: string) => `\x1b[35m${s}\x1b[0m`;
|
|
||||||
const white = (s: string) => `\x1b[97m${s}\x1b[0m`;
|
|
||||||
const bold = (s: string) => `\x1b[1m${s}\x1b[22m`;
|
|
||||||
|
|
||||||
const boxWidth = GAME_WIDTH;
|
|
||||||
|
|
||||||
const boxLine = (content: string) => {
|
|
||||||
const contentLen = visibleWidth(content);
|
|
||||||
const padding = Math.max(0, boxWidth - contentLen);
|
|
||||||
return dim(" │") + content + " ".repeat(padding) + dim("│");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Top border
|
|
||||||
lines.push(this.padLine(dim(` ╭${"─".repeat(boxWidth)}╮`), width));
|
|
||||||
|
|
||||||
// Header
|
|
||||||
const title = `${bold(green("SPACE INVADERS"))}`;
|
|
||||||
const scoreText = `Score: ${bold(yellow(String(this.state.score)))}`;
|
|
||||||
const highText = `Hi: ${bold(yellow(String(this.state.highScore)))}`;
|
|
||||||
const levelText = `Lv: ${bold(cyan(String(this.state.level)))}`;
|
|
||||||
const livesText = `${red("♥".repeat(this.state.player.lives))}`;
|
|
||||||
const header = `${title} │ ${scoreText} │ ${highText} │ ${levelText} │ ${livesText}`;
|
|
||||||
lines.push(this.padLine(boxLine(header), width));
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
||||||
|
|
||||||
// Game grid
|
|
||||||
for (let y = 0; y < GAME_HEIGHT; y++) {
|
|
||||||
let row = "";
|
|
||||||
for (let x = 0; x < GAME_WIDTH; x++) {
|
|
||||||
let char = " ";
|
|
||||||
let colored = false;
|
|
||||||
|
|
||||||
// Check aliens
|
|
||||||
for (const alien of this.state.aliens) {
|
|
||||||
if (alien.alive && alien.y === y && Math.abs(alien.x - x) <= 1) {
|
|
||||||
const sprites = [
|
|
||||||
x === alien.x ? "▼" : "╲╱"[x < alien.x ? 0 : 1],
|
|
||||||
x === alien.x ? "◆" : "╱╲"[x < alien.x ? 0 : 1],
|
|
||||||
x === alien.x ? "☆" : "◄►"[x < alien.x ? 0 : 1],
|
|
||||||
];
|
|
||||||
const colors = [green, cyan, magenta];
|
|
||||||
char = colors[alien.type](sprites[alien.type]);
|
|
||||||
colored = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check shields
|
|
||||||
if (!colored) {
|
|
||||||
for (const shield of this.state.shields) {
|
|
||||||
const relX = x - shield.x;
|
|
||||||
const relY = y - (PLAYER_Y - 5);
|
|
||||||
if (relX >= 0 && relX < 4 && relY >= 0 && relY < 3) {
|
|
||||||
if (shield.segments[relY][relX]) {
|
|
||||||
char = dim("█");
|
|
||||||
colored = true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check player
|
|
||||||
if (!colored && y === PLAYER_Y && Math.abs(x - this.state.player.x) <= 1) {
|
|
||||||
if (x === this.state.player.x) {
|
|
||||||
char = white("▲");
|
|
||||||
} else {
|
|
||||||
char = white("═");
|
|
||||||
}
|
|
||||||
colored = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check bullets
|
|
||||||
if (!colored) {
|
|
||||||
for (const bullet of this.state.bullets) {
|
|
||||||
if (bullet.x === x && bullet.y === y) {
|
|
||||||
char = bullet.direction === -1 ? yellow("│") : red("│");
|
|
||||||
colored = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
row += colored ? char : " ";
|
|
||||||
}
|
|
||||||
lines.push(this.padLine(dim(" │") + row + dim("│"), width));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
lines.push(this.padLine(dim(` ├${"─".repeat(boxWidth)}┤`), width));
|
|
||||||
|
|
||||||
// Footer
|
|
||||||
let footer: string;
|
|
||||||
if (this.paused) {
|
|
||||||
footer = `${yellow(bold("PAUSED"))} Press any key to continue, ${bold("Q")} to quit`;
|
|
||||||
} else if (this.state.gameOver) {
|
|
||||||
footer = `${red(bold("GAME OVER!"))} Press ${bold("R")} to restart, ${bold("Q")} to quit`;
|
|
||||||
} else if (this.state.victory) {
|
|
||||||
footer = `${green(bold("VICTORY!"))} Press ${bold("R")} for level ${this.state.level + 1}, ${bold("Q")} to quit`;
|
|
||||||
} else {
|
|
||||||
footer = `←→ or AD to move, ${bold("SPACE")}/F to fire, ${bold("ESC")} pause, ${bold("Q")} quit`;
|
|
||||||
}
|
|
||||||
lines.push(this.padLine(boxLine(footer), width));
|
|
||||||
|
|
||||||
// Bottom border
|
|
||||||
lines.push(this.padLine(dim(` ╰${"─".repeat(boxWidth)}╯`), width));
|
|
||||||
|
|
||||||
this.cachedLines = lines;
|
|
||||||
this.cachedWidth = width;
|
|
||||||
this.cachedVersion = this.version;
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
private padLine(line: string, width: number): string {
|
|
||||||
const visibleLen = line.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
||||||
const padding = Math.max(0, width - visibleLen);
|
|
||||||
return line + " ".repeat(padding);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this.interval) {
|
|
||||||
clearInterval(this.interval);
|
|
||||||
this.interval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const INVADERS_SAVE_TYPE = "space-invaders-save";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerCommand("invaders", {
|
|
||||||
description: "Play Space Invaders!",
|
|
||||||
|
|
||||||
handler: async (_args, ctx) => {
|
|
||||||
if (!ctx.hasUI) {
|
|
||||||
ctx.ui.notify("Space Invaders requires interactive mode", "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load saved state from session
|
|
||||||
const entries = ctx.sessionManager.getEntries();
|
|
||||||
let savedState: GameState | undefined;
|
|
||||||
for (let i = entries.length - 1; i >= 0; i--) {
|
|
||||||
const entry = entries[i];
|
|
||||||
if (entry.type === "custom" && entry.customType === INVADERS_SAVE_TYPE) {
|
|
||||||
savedState = entry.data as GameState;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.ui.custom((tui, _theme, _kb, done) => {
|
|
||||||
return new SpaceInvadersComponent(
|
|
||||||
tui,
|
|
||||||
() => done(undefined),
|
|
||||||
(state) => {
|
|
||||||
pi.appendEntry(INVADERS_SAVE_TYPE, state);
|
|
||||||
},
|
|
||||||
savedState,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
/**
|
|
||||||
* SSH Remote Execution Example
|
|
||||||
*
|
|
||||||
* Demonstrates delegating tool operations to a remote machine via SSH.
|
|
||||||
* When --ssh is provided, read/write/edit/bash run on the remote.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* pi -e ./ssh.ts --ssh user@host
|
|
||||||
* pi -e ./ssh.ts --ssh user@host:/remote/path
|
|
||||||
*
|
|
||||||
* Requirements:
|
|
||||||
* - SSH key-based auth (no password prompts)
|
|
||||||
* - bash on remote
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
import {
|
|
||||||
type BashOperations,
|
|
||||||
createBashTool,
|
|
||||||
createEditTool,
|
|
||||||
createReadTool,
|
|
||||||
createWriteTool,
|
|
||||||
type EditOperations,
|
|
||||||
type ReadOperations,
|
|
||||||
type WriteOperations,
|
|
||||||
} from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
function sshExec(remote: string, command: string): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn("ssh", [remote, command], { stdio: ["ignore", "pipe", "pipe"] });
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
const errChunks: Buffer[] = [];
|
|
||||||
child.stdout.on("data", (data) => chunks.push(data));
|
|
||||||
child.stderr.on("data", (data) => errChunks.push(data));
|
|
||||||
child.on("error", reject);
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
reject(new Error(`SSH failed (${code}): ${Buffer.concat(errChunks).toString()}`));
|
|
||||||
} else {
|
|
||||||
resolve(Buffer.concat(chunks));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRemoteReadOps(remote: string, remoteCwd: string, localCwd: string): ReadOperations {
|
|
||||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
|
||||||
return {
|
|
||||||
readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`),
|
|
||||||
access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
|
|
||||||
detectImageMimeType: async (p) => {
|
|
||||||
try {
|
|
||||||
const r = await sshExec(remote, `file --mime-type -b ${JSON.stringify(toRemote(p))}`);
|
|
||||||
const m = r.toString().trim();
|
|
||||||
return ["image/jpeg", "image/png", "image/gif", "image/webp"].includes(m) ? m : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRemoteWriteOps(remote: string, remoteCwd: string, localCwd: string): WriteOperations {
|
|
||||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
|
||||||
return {
|
|
||||||
writeFile: async (p, content) => {
|
|
||||||
const b64 = Buffer.from(content).toString("base64");
|
|
||||||
await sshExec(remote, `echo ${JSON.stringify(b64)} | base64 -d > ${JSON.stringify(toRemote(p))}`);
|
|
||||||
},
|
|
||||||
mkdir: (dir) => sshExec(remote, `mkdir -p ${JSON.stringify(toRemote(dir))}`).then(() => {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRemoteEditOps(remote: string, remoteCwd: string, localCwd: string): EditOperations {
|
|
||||||
const r = createRemoteReadOps(remote, remoteCwd, localCwd);
|
|
||||||
const w = createRemoteWriteOps(remote, remoteCwd, localCwd);
|
|
||||||
return { readFile: r.readFile, access: r.access, writeFile: w.writeFile };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRemoteBashOps(remote: string, remoteCwd: string, localCwd: string): BashOperations {
|
|
||||||
const toRemote = (p: string) => p.replace(localCwd, remoteCwd);
|
|
||||||
return {
|
|
||||||
exec: (command, cwd, { onData, signal, timeout }) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
const cmd = `cd ${JSON.stringify(toRemote(cwd))} && ${command}`;
|
|
||||||
const child = spawn("ssh", [remote, cmd], { stdio: ["ignore", "pipe", "pipe"] });
|
|
||||||
let timedOut = false;
|
|
||||||
const timer = timeout
|
|
||||||
? setTimeout(() => {
|
|
||||||
timedOut = true;
|
|
||||||
child.kill();
|
|
||||||
}, timeout * 1000)
|
|
||||||
: undefined;
|
|
||||||
child.stdout.on("data", onData);
|
|
||||||
child.stderr.on("data", onData);
|
|
||||||
child.on("error", (e) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
reject(e);
|
|
||||||
});
|
|
||||||
const onAbort = () => child.kill();
|
|
||||||
signal?.addEventListener("abort", onAbort, { once: true });
|
|
||||||
child.on("close", (code) => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
signal?.removeEventListener("abort", onAbort);
|
|
||||||
if (signal?.aborted) reject(new Error("aborted"));
|
|
||||||
else if (timedOut) reject(new Error(`timeout:${timeout}`));
|
|
||||||
else resolve({ exitCode: code });
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerFlag("ssh", { description: "SSH remote: user@host or user@host:/path", type: "string" });
|
|
||||||
|
|
||||||
const localCwd = process.cwd();
|
|
||||||
const localRead = createReadTool(localCwd);
|
|
||||||
const localWrite = createWriteTool(localCwd);
|
|
||||||
const localEdit = createEditTool(localCwd);
|
|
||||||
const localBash = createBashTool(localCwd);
|
|
||||||
|
|
||||||
// Resolved lazily on session_start (CLI flags not available during factory)
|
|
||||||
let resolvedSsh: { remote: string; remoteCwd: string } | null = null;
|
|
||||||
|
|
||||||
const getSsh = () => resolvedSsh;
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...localRead,
|
|
||||||
async execute(id, params, signal, onUpdate, _ctx) {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (ssh) {
|
|
||||||
const tool = createReadTool(localCwd, {
|
|
||||||
operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
||||||
});
|
|
||||||
return tool.execute(id, params, signal, onUpdate);
|
|
||||||
}
|
|
||||||
return localRead.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...localWrite,
|
|
||||||
async execute(id, params, signal, onUpdate, _ctx) {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (ssh) {
|
|
||||||
const tool = createWriteTool(localCwd, {
|
|
||||||
operations: createRemoteWriteOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
||||||
});
|
|
||||||
return tool.execute(id, params, signal, onUpdate);
|
|
||||||
}
|
|
||||||
return localWrite.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...localEdit,
|
|
||||||
async execute(id, params, signal, onUpdate, _ctx) {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (ssh) {
|
|
||||||
const tool = createEditTool(localCwd, {
|
|
||||||
operations: createRemoteEditOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
||||||
});
|
|
||||||
return tool.execute(id, params, signal, onUpdate);
|
|
||||||
}
|
|
||||||
return localEdit.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.registerTool({
|
|
||||||
...localBash,
|
|
||||||
async execute(id, params, signal, onUpdate, _ctx) {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (ssh) {
|
|
||||||
const tool = createBashTool(localCwd, {
|
|
||||||
operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd),
|
|
||||||
});
|
|
||||||
return tool.execute(id, params, signal, onUpdate);
|
|
||||||
}
|
|
||||||
return localBash.execute(id, params, signal, onUpdate);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
// Resolve SSH config now that CLI flags are available
|
|
||||||
const arg = pi.getFlag("ssh") as string | undefined;
|
|
||||||
if (arg) {
|
|
||||||
if (arg.includes(":")) {
|
|
||||||
const [remote, path] = arg.split(":");
|
|
||||||
resolvedSsh = { remote, remoteCwd: path };
|
|
||||||
} else {
|
|
||||||
// No path given, evaluate pwd on remote
|
|
||||||
const remote = arg;
|
|
||||||
const pwd = (await sshExec(remote, "pwd")).toString().trim();
|
|
||||||
resolvedSsh = { remote, remoteCwd: pwd };
|
|
||||||
}
|
|
||||||
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${resolvedSsh.remote}:${resolvedSsh.remoteCwd}`));
|
|
||||||
ctx.ui.notify(`SSH mode: ${resolvedSsh.remote}:${resolvedSsh.remoteCwd}`, "info");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle user ! commands via SSH
|
|
||||||
pi.on("user_bash", (_event) => {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (!ssh) return; // No SSH, use local execution
|
|
||||||
return { operations: createRemoteBashOps(ssh.remote, ssh.remoteCwd, localCwd) };
|
|
||||||
});
|
|
||||||
|
|
||||||
// Replace local cwd with remote cwd in system prompt
|
|
||||||
pi.on("before_agent_start", async (event) => {
|
|
||||||
const ssh = getSsh();
|
|
||||||
if (ssh) {
|
|
||||||
const modified = event.systemPrompt.replace(
|
|
||||||
`Current working directory: ${localCwd}`,
|
|
||||||
`Current working directory: ${ssh.remoteCwd} (via SSH: ${ssh.remote})`,
|
|
||||||
);
|
|
||||||
return { systemPrompt: modified };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
/**
|
|
||||||
* Status Line Extension
|
|
||||||
*
|
|
||||||
* Demonstrates ctx.ui.setStatus() for displaying persistent status text in the footer.
|
|
||||||
* Shows turn progress with themed colors.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
let turnCount = 0;
|
|
||||||
|
|
||||||
pi.on("session_start", async (_event, ctx) => {
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("turn_start", async (_event, ctx) => {
|
|
||||||
turnCount++;
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
const spinner = theme.fg("accent", "●");
|
|
||||||
const text = theme.fg("dim", ` Turn ${turnCount}...`);
|
|
||||||
ctx.ui.setStatus("status-demo", spinner + text);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("turn_end", async (_event, ctx) => {
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
const check = theme.fg("success", "✓");
|
|
||||||
const text = theme.fg("dim", ` Turn ${turnCount} complete`);
|
|
||||||
ctx.ui.setStatus("status-demo", check + text);
|
|
||||||
});
|
|
||||||
|
|
||||||
pi.on("session_switch", async (event, ctx) => {
|
|
||||||
if (event.reason === "new") {
|
|
||||||
turnCount = 0;
|
|
||||||
const theme = ctx.ui.theme;
|
|
||||||
ctx.ui.setStatus("status-demo", theme.fg("dim", "Ready"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
# Subagent Example
|
|
||||||
|
|
||||||
Delegate tasks to specialized subagents with isolated context windows.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Isolated context**: Each subagent runs in a separate `pi` process
|
|
||||||
- **Streaming output**: See tool calls and progress as they happen
|
|
||||||
- **Parallel streaming**: All parallel tasks stream updates simultaneously
|
|
||||||
- **Markdown rendering**: Final output rendered with proper formatting (expanded view)
|
|
||||||
- **Usage tracking**: Shows turns, tokens, cost, and context usage per agent
|
|
||||||
- **Abort support**: Ctrl+C propagates to kill subagent processes
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
subagent/
|
|
||||||
├── README.md # This file
|
|
||||||
├── index.ts # The extension (entry point)
|
|
||||||
├── agents.ts # Agent discovery logic
|
|
||||||
├── agents/ # Sample agent definitions
|
|
||||||
│ ├── scout.md # Fast recon, returns compressed context
|
|
||||||
│ ├── planner.md # Creates implementation plans
|
|
||||||
│ ├── reviewer.md # Code review
|
|
||||||
│ └── worker.md # General-purpose (full capabilities)
|
|
||||||
└── prompts/ # Workflow presets (prompt templates)
|
|
||||||
├── implement.md # scout -> planner -> worker
|
|
||||||
├── scout-and-plan.md # scout -> planner (no implementation)
|
|
||||||
└── implement-and-review.md # worker -> reviewer -> worker
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
From the repository root, symlink the files:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Symlink the extension (must be in a subdirectory with index.ts)
|
|
||||||
mkdir -p ~/.pi/agent/extensions/subagent
|
|
||||||
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/index.ts" ~/.pi/agent/extensions/subagent/index.ts
|
|
||||||
ln -sf "$(pwd)/packages/coding-agent/examples/extensions/subagent/agents.ts" ~/.pi/agent/extensions/subagent/agents.ts
|
|
||||||
|
|
||||||
# Symlink agents
|
|
||||||
mkdir -p ~/.pi/agent/agents
|
|
||||||
for f in packages/coding-agent/examples/extensions/subagent/agents/*.md; do
|
|
||||||
ln -sf "$(pwd)/$f" ~/.pi/agent/agents/$(basename "$f")
|
|
||||||
done
|
|
||||||
|
|
||||||
# Symlink workflow prompts
|
|
||||||
mkdir -p ~/.pi/agent/prompts
|
|
||||||
for f in packages/coding-agent/examples/extensions/subagent/prompts/*.md; do
|
|
||||||
ln -sf "$(pwd)/$f" ~/.pi/agent/prompts/$(basename "$f")
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
This tool executes a separate `pi` subprocess with a delegated system prompt and tool/model configuration.
|
|
||||||
|
|
||||||
**Project-local agents** (`.pi/agents/*.md`) are repo-controlled prompts that can instruct the model to read files, run bash commands, etc.
|
|
||||||
|
|
||||||
**Default behavior:** Only loads **user-level agents** from `~/.pi/agent/agents`.
|
|
||||||
|
|
||||||
To enable project-local agents, pass `agentScope: "both"` (or `"project"`). Only do this for repositories you trust.
|
|
||||||
|
|
||||||
When running interactively, the tool prompts for confirmation before running project-local agents. Set `confirmProjectAgents: false` to disable.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### Single agent
|
|
||||||
```
|
|
||||||
Use scout to find all authentication code
|
|
||||||
```
|
|
||||||
|
|
||||||
### Parallel execution
|
|
||||||
```
|
|
||||||
Run 2 scouts in parallel: one to find models, one to find providers
|
|
||||||
```
|
|
||||||
|
|
||||||
### Chained workflow
|
|
||||||
```
|
|
||||||
Use a chain: first have scout find the read tool, then have planner suggest improvements
|
|
||||||
```
|
|
||||||
|
|
||||||
### Workflow prompts
|
|
||||||
```
|
|
||||||
/implement add Redis caching to the session store
|
|
||||||
/scout-and-plan refactor auth to support OAuth
|
|
||||||
/implement-and-review add input validation to API endpoints
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tool Modes
|
|
||||||
|
|
||||||
| Mode | Parameter | Description |
|
|
||||||
|------|-----------|-------------|
|
|
||||||
| Single | `{ agent, task }` | One agent, one task |
|
|
||||||
| Parallel | `{ tasks: [...] }` | Multiple agents run concurrently (max 8, 4 concurrent) |
|
|
||||||
| Chain | `{ chain: [...] }` | Sequential with `{previous}` placeholder |
|
|
||||||
|
|
||||||
## Output Display
|
|
||||||
|
|
||||||
**Collapsed view** (default):
|
|
||||||
- Status icon (✓/✗/⏳) and agent name
|
|
||||||
- Last 5-10 items (tool calls and text)
|
|
||||||
- Usage stats: `3 turns ↑input ↓output RcacheRead WcacheWrite $cost ctx:contextTokens model`
|
|
||||||
|
|
||||||
**Expanded view** (Ctrl+O):
|
|
||||||
- Full task text
|
|
||||||
- All tool calls with formatted arguments
|
|
||||||
- Final output rendered as Markdown
|
|
||||||
- Per-task usage (for chain/parallel)
|
|
||||||
|
|
||||||
**Parallel mode streaming**:
|
|
||||||
- Shows all tasks with live status (⏳ running, ✓ done, ✗ failed)
|
|
||||||
- Updates as each task makes progress
|
|
||||||
- Shows "2/3 done, 1 running" status
|
|
||||||
|
|
||||||
**Tool call formatting** (mimics built-in tools):
|
|
||||||
- `$ command` for bash
|
|
||||||
- `read ~/path:1-10` for read
|
|
||||||
- `grep /pattern/ in ~/path` for grep
|
|
||||||
- etc.
|
|
||||||
|
|
||||||
## Agent Definitions
|
|
||||||
|
|
||||||
Agents are markdown files with YAML frontmatter:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
name: my-agent
|
|
||||||
description: What this agent does
|
|
||||||
tools: read, grep, find, ls
|
|
||||||
model: claude-haiku-4-5
|
|
||||||
---
|
|
||||||
|
|
||||||
System prompt for the agent goes here.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Locations:**
|
|
||||||
- `~/.pi/agent/agents/*.md` - User-level (always loaded)
|
|
||||||
- `.pi/agents/*.md` - Project-level (only with `agentScope: "project"` or `"both"`)
|
|
||||||
|
|
||||||
Project agents override user agents with the same name when `agentScope: "both"`.
|
|
||||||
|
|
||||||
## Sample Agents
|
|
||||||
|
|
||||||
| Agent | Purpose | Model | Tools |
|
|
||||||
|-------|---------|-------|-------|
|
|
||||||
| `scout` | Fast codebase recon | Haiku | read, grep, find, ls, bash |
|
|
||||||
| `planner` | Implementation plans | Sonnet | read, grep, find, ls |
|
|
||||||
| `reviewer` | Code review | Sonnet | read, grep, find, ls, bash |
|
|
||||||
| `worker` | General-purpose | Sonnet | (all default) |
|
|
||||||
|
|
||||||
## Workflow Prompts
|
|
||||||
|
|
||||||
| Prompt | Flow |
|
|
||||||
|--------|------|
|
|
||||||
| `/implement <query>` | scout → planner → worker |
|
|
||||||
| `/scout-and-plan <query>` | scout → planner |
|
|
||||||
| `/implement-and-review <query>` | worker → reviewer → worker |
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
- **Exit code != 0**: Tool returns error with stderr/output
|
|
||||||
- **stopReason "error"**: LLM error propagated with error message
|
|
||||||
- **stopReason "aborted"**: User abort (Ctrl+C) kills subprocess, throws error
|
|
||||||
- **Chain mode**: Stops at first failing step, reports which step failed
|
|
||||||
|
|
||||||
## Limitations
|
|
||||||
|
|
||||||
- Output truncated to last 10 items in collapsed view (expand to see all)
|
|
||||||
- Agents discovered fresh on each invocation (allows editing mid-session)
|
|
||||||
- Parallel mode limited to 8 tasks, 4 concurrent
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
/**
|
|
||||||
* Agent discovery and configuration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
|
||||||
|
|
||||||
export type AgentScope = "user" | "project" | "both";
|
|
||||||
|
|
||||||
export interface AgentConfig {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
tools?: string[];
|
|
||||||
model?: string;
|
|
||||||
systemPrompt: string;
|
|
||||||
source: "user" | "project";
|
|
||||||
filePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentDiscoveryResult {
|
|
||||||
agents: AgentConfig[];
|
|
||||||
projectAgentsDir: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
|
||||||
const agents: AgentConfig[] = [];
|
|
||||||
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
let entries: fs.Dirent[];
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.name.endsWith(".md")) continue;
|
|
||||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
|
||||||
|
|
||||||
const filePath = path.join(dir, entry.name);
|
|
||||||
let content: string;
|
|
||||||
try {
|
|
||||||
content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
|
||||||
|
|
||||||
if (!frontmatter.name || !frontmatter.description) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tools = frontmatter.tools
|
|
||||||
?.split(",")
|
|
||||||
.map((t: string) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
agents.push({
|
|
||||||
name: frontmatter.name,
|
|
||||||
description: frontmatter.description,
|
|
||||||
tools: tools && tools.length > 0 ? tools : undefined,
|
|
||||||
model: frontmatter.model,
|
|
||||||
systemPrompt: body,
|
|
||||||
source,
|
|
||||||
filePath,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isDirectory(p: string): boolean {
|
|
||||||
try {
|
|
||||||
return fs.statSync(p).isDirectory();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNearestProjectAgentsDir(cwd: string): string | null {
|
|
||||||
let currentDir = cwd;
|
|
||||||
while (true) {
|
|
||||||
const candidate = path.join(currentDir, ".pi", "agents");
|
|
||||||
if (isDirectory(candidate)) return candidate;
|
|
||||||
|
|
||||||
const parentDir = path.dirname(currentDir);
|
|
||||||
if (parentDir === currentDir) return null;
|
|
||||||
currentDir = parentDir;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
|
||||||
const userDir = path.join(getAgentDir(), "agents");
|
|
||||||
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
|
||||||
|
|
||||||
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
|
||||||
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
|
||||||
|
|
||||||
const agentMap = new Map<string, AgentConfig>();
|
|
||||||
|
|
||||||
if (scope === "both") {
|
|
||||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
||||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
||||||
} else if (scope === "user") {
|
|
||||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
|
||||||
} else {
|
|
||||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
|
||||||
if (agents.length === 0) return { text: "none", remaining: 0 };
|
|
||||||
const listed = agents.slice(0, maxItems);
|
|
||||||
const remaining = agents.length - listed.length;
|
|
||||||
return {
|
|
||||||
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
|
||||||
remaining,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
---
|
|
||||||
name: planner
|
|
||||||
description: Creates implementation plans from context and requirements
|
|
||||||
tools: read, grep, find, ls
|
|
||||||
model: claude-sonnet-4-5
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a planning specialist. You receive context (from a scout) and requirements, then produce a clear implementation plan.
|
|
||||||
|
|
||||||
You must NOT make any changes. Only read, analyze, and plan.
|
|
||||||
|
|
||||||
Input format you'll receive:
|
|
||||||
- Context/findings from a scout agent
|
|
||||||
- Original query or requirements
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
One sentence summary of what needs to be done.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
Numbered steps, each small and actionable:
|
|
||||||
1. Step one - specific file/function to modify
|
|
||||||
2. Step two - what to add/change
|
|
||||||
3. ...
|
|
||||||
|
|
||||||
## Files to Modify
|
|
||||||
- `path/to/file.ts` - what changes
|
|
||||||
- `path/to/other.ts` - what changes
|
|
||||||
|
|
||||||
## New Files (if any)
|
|
||||||
- `path/to/new.ts` - purpose
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
Anything to watch out for.
|
|
||||||
|
|
||||||
Keep the plan concrete. The worker agent will execute it verbatim.
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
---
|
|
||||||
name: reviewer
|
|
||||||
description: Code review specialist for quality and security analysis
|
|
||||||
tools: read, grep, find, ls, bash
|
|
||||||
model: claude-sonnet-4-5
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a senior code reviewer. Analyze code for quality, security, and maintainability.
|
|
||||||
|
|
||||||
Bash is for read-only commands only: `git diff`, `git log`, `git show`. Do NOT modify files or run builds.
|
|
||||||
Assume tool permissions are not perfectly enforceable; keep all bash usage strictly read-only.
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. Run `git diff` to see recent changes (if applicable)
|
|
||||||
2. Read the modified files
|
|
||||||
3. Check for bugs, security issues, code smells
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
|
|
||||||
## Files Reviewed
|
|
||||||
- `path/to/file.ts` (lines X-Y)
|
|
||||||
|
|
||||||
## Critical (must fix)
|
|
||||||
- `file.ts:42` - Issue description
|
|
||||||
|
|
||||||
## Warnings (should fix)
|
|
||||||
- `file.ts:100` - Issue description
|
|
||||||
|
|
||||||
## Suggestions (consider)
|
|
||||||
- `file.ts:150` - Improvement idea
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Overall assessment in 2-3 sentences.
|
|
||||||
|
|
||||||
Be specific with file paths and line numbers.
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
---
|
|
||||||
name: scout
|
|
||||||
description: Fast codebase recon that returns compressed context for handoff to other agents
|
|
||||||
tools: read, grep, find, ls, bash
|
|
||||||
model: claude-haiku-4-5
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a scout. Quickly investigate a codebase and return structured findings that another agent can use without re-reading everything.
|
|
||||||
|
|
||||||
Your output will be passed to an agent who has NOT seen the files you explored.
|
|
||||||
|
|
||||||
Thoroughness (infer from task, default medium):
|
|
||||||
- Quick: Targeted lookups, key files only
|
|
||||||
- Medium: Follow imports, read critical sections
|
|
||||||
- Thorough: Trace all dependencies, check tests/types
|
|
||||||
|
|
||||||
Strategy:
|
|
||||||
1. grep/find to locate relevant code
|
|
||||||
2. Read key sections (not entire files)
|
|
||||||
3. Identify types, interfaces, key functions
|
|
||||||
4. Note dependencies between files
|
|
||||||
|
|
||||||
Output format:
|
|
||||||
|
|
||||||
## Files Retrieved
|
|
||||||
List with exact line ranges:
|
|
||||||
1. `path/to/file.ts` (lines 10-50) - Description of what's here
|
|
||||||
2. `path/to/other.ts` (lines 100-150) - Description
|
|
||||||
3. ...
|
|
||||||
|
|
||||||
## Key Code
|
|
||||||
Critical types, interfaces, or functions:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Example {
|
|
||||||
// actual code from the files
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function keyFunction() {
|
|
||||||
// actual implementation
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
Brief explanation of how the pieces connect.
|
|
||||||
|
|
||||||
## Start Here
|
|
||||||
Which file to look at first and why.
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
name: worker
|
|
||||||
description: General-purpose subagent with full capabilities, isolated context
|
|
||||||
model: claude-sonnet-4-5
|
|
||||||
---
|
|
||||||
|
|
||||||
You are a worker agent with full capabilities. You operate in an isolated context window to handle delegated tasks without polluting the main conversation.
|
|
||||||
|
|
||||||
Work autonomously to complete the assigned task. Use all available tools as needed.
|
|
||||||
|
|
||||||
Output format when finished:
|
|
||||||
|
|
||||||
## Completed
|
|
||||||
What was done.
|
|
||||||
|
|
||||||
## Files Changed
|
|
||||||
- `path/to/file.ts` - what changed
|
|
||||||
|
|
||||||
## Notes (if any)
|
|
||||||
Anything the main agent should know.
|
|
||||||
|
|
||||||
If handing off to another agent (e.g. reviewer), include:
|
|
||||||
- Exact file paths changed
|
|
||||||
- Key functions/types touched (short list)
|
|
||||||
|
|
@ -1,964 +0,0 @@
|
||||||
/**
|
|
||||||
* Subagent Tool - Delegate tasks to specialized agents
|
|
||||||
*
|
|
||||||
* Spawns a separate `pi` process for each subagent invocation,
|
|
||||||
* giving it an isolated context window.
|
|
||||||
*
|
|
||||||
* Supports three modes:
|
|
||||||
* - Single: { agent: "name", task: "..." }
|
|
||||||
* - Parallel: { tasks: [{ agent: "name", task: "..." }, ...] }
|
|
||||||
* - Chain: { chain: [{ agent: "name", task: "... {previous} ..." }, ...] }
|
|
||||||
*
|
|
||||||
* Uses JSON mode to capture structured output from subagents.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import * as fs from "node:fs";
|
|
||||||
import * as os from "node:os";
|
|
||||||
import * as path from "node:path";
|
|
||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
|
||||||
import type { Message } from "@mariozechner/pi-ai";
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
|
||||||
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
|
||||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
|
||||||
import { Type } from "@sinclair/typebox";
|
|
||||||
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.js";
|
|
||||||
|
|
||||||
const MAX_PARALLEL_TASKS = 8;
|
|
||||||
const MAX_CONCURRENCY = 4;
|
|
||||||
const COLLAPSED_ITEM_COUNT = 10;
|
|
||||||
|
|
||||||
function formatTokens(count: number): string {
|
|
||||||
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 formatUsageStats(
|
|
||||||
usage: {
|
|
||||||
input: number;
|
|
||||||
output: number;
|
|
||||||
cacheRead: number;
|
|
||||||
cacheWrite: number;
|
|
||||||
cost: number;
|
|
||||||
contextTokens?: number;
|
|
||||||
turns?: number;
|
|
||||||
},
|
|
||||||
model?: string,
|
|
||||||
): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
|
||||||
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
|
||||||
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
|
||||||
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
|
||||||
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
|
||||||
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
|
||||||
if (usage.contextTokens && usage.contextTokens > 0) {
|
|
||||||
parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
|
||||||
}
|
|
||||||
if (model) parts.push(model);
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolCall(
|
|
||||||
toolName: string,
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
themeFg: (color: any, text: string) => string,
|
|
||||||
): string {
|
|
||||||
const shortenPath = (p: string) => {
|
|
||||||
const home = os.homedir();
|
|
||||||
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (toolName) {
|
|
||||||
case "bash": {
|
|
||||||
const command = (args.command as string) || "...";
|
|
||||||
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
|
||||||
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
|
||||||
}
|
|
||||||
case "read": {
|
|
||||||
const rawPath = (args.file_path || args.path || "...") as string;
|
|
||||||
const filePath = shortenPath(rawPath);
|
|
||||||
const offset = args.offset as number | undefined;
|
|
||||||
const limit = args.limit as number | undefined;
|
|
||||||
let text = themeFg("accent", filePath);
|
|
||||||
if (offset !== undefined || limit !== undefined) {
|
|
||||||
const startLine = offset ?? 1;
|
|
||||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
|
||||||
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
|
||||||
}
|
|
||||||
return themeFg("muted", "read ") + text;
|
|
||||||
}
|
|
||||||
case "write": {
|
|
||||||
const rawPath = (args.file_path || args.path || "...") as string;
|
|
||||||
const filePath = shortenPath(rawPath);
|
|
||||||
const content = (args.content || "") as string;
|
|
||||||
const lines = content.split("\n").length;
|
|
||||||
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
|
|
||||||
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
case "edit": {
|
|
||||||
const rawPath = (args.file_path || args.path || "...") as string;
|
|
||||||
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
|
||||||
}
|
|
||||||
case "ls": {
|
|
||||||
const rawPath = (args.path || ".") as string;
|
|
||||||
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
|
||||||
}
|
|
||||||
case "find": {
|
|
||||||
const pattern = (args.pattern || "*") as string;
|
|
||||||
const rawPath = (args.path || ".") as string;
|
|
||||||
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
|
||||||
}
|
|
||||||
case "grep": {
|
|
||||||
const pattern = (args.pattern || "") as string;
|
|
||||||
const rawPath = (args.path || ".") as string;
|
|
||||||
return (
|
|
||||||
themeFg("muted", "grep ") +
|
|
||||||
themeFg("accent", `/${pattern}/`) +
|
|
||||||
themeFg("dim", ` in ${shortenPath(rawPath)}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
const argsStr = JSON.stringify(args);
|
|
||||||
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
|
||||||
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsageStats {
|
|
||||||
input: number;
|
|
||||||
output: number;
|
|
||||||
cacheRead: number;
|
|
||||||
cacheWrite: number;
|
|
||||||
cost: number;
|
|
||||||
contextTokens: number;
|
|
||||||
turns: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SingleResult {
|
|
||||||
agent: string;
|
|
||||||
agentSource: "user" | "project" | "unknown";
|
|
||||||
task: string;
|
|
||||||
exitCode: number;
|
|
||||||
messages: Message[];
|
|
||||||
stderr: string;
|
|
||||||
usage: UsageStats;
|
|
||||||
model?: string;
|
|
||||||
stopReason?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
step?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SubagentDetails {
|
|
||||||
mode: "single" | "parallel" | "chain";
|
|
||||||
agentScope: AgentScope;
|
|
||||||
projectAgentsDir: string | null;
|
|
||||||
results: SingleResult[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFinalOutput(messages: Message[]): string {
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const msg = messages[i];
|
|
||||||
if (msg.role === "assistant") {
|
|
||||||
for (const part of msg.content) {
|
|
||||||
if (part.type === "text") return part.text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
|
|
||||||
|
|
||||||
function getDisplayItems(messages: Message[]): DisplayItem[] {
|
|
||||||
const items: DisplayItem[] = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (msg.role === "assistant") {
|
|
||||||
for (const part of msg.content) {
|
|
||||||
if (part.type === "text") items.push({ type: "text", text: part.text });
|
|
||||||
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mapWithConcurrencyLimit<TIn, TOut>(
|
|
||||||
items: TIn[],
|
|
||||||
concurrency: number,
|
|
||||||
fn: (item: TIn, index: number) => Promise<TOut>,
|
|
||||||
): Promise<TOut[]> {
|
|
||||||
if (items.length === 0) return [];
|
|
||||||
const limit = Math.max(1, Math.min(concurrency, items.length));
|
|
||||||
const results: TOut[] = new Array(items.length);
|
|
||||||
let nextIndex = 0;
|
|
||||||
const workers = new Array(limit).fill(null).map(async () => {
|
|
||||||
while (true) {
|
|
||||||
const current = nextIndex++;
|
|
||||||
if (current >= items.length) return;
|
|
||||||
results[current] = await fn(items[current], current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await Promise.all(workers);
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
|
|
||||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
|
|
||||||
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
||||||
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
|
||||||
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
|
||||||
return { dir: tmpDir, filePath };
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnUpdateCallback = (partial: AgentToolResult<SubagentDetails>) => void;
|
|
||||||
|
|
||||||
async function runSingleAgent(
|
|
||||||
defaultCwd: string,
|
|
||||||
agents: AgentConfig[],
|
|
||||||
agentName: string,
|
|
||||||
task: string,
|
|
||||||
cwd: string | undefined,
|
|
||||||
step: number | undefined,
|
|
||||||
signal: AbortSignal | undefined,
|
|
||||||
onUpdate: OnUpdateCallback | undefined,
|
|
||||||
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
|
||||||
): Promise<SingleResult> {
|
|
||||||
const agent = agents.find((a) => a.name === agentName);
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
|
||||||
return {
|
|
||||||
agent: agentName,
|
|
||||||
agentSource: "unknown",
|
|
||||||
task,
|
|
||||||
exitCode: 1,
|
|
||||||
messages: [],
|
|
||||||
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
||||||
step,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
|
||||||
if (agent.model) args.push("--model", agent.model);
|
|
||||||
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
|
||||||
|
|
||||||
let tmpPromptDir: string | null = null;
|
|
||||||
let tmpPromptPath: string | null = null;
|
|
||||||
|
|
||||||
const currentResult: SingleResult = {
|
|
||||||
agent: agentName,
|
|
||||||
agentSource: agent.source,
|
|
||||||
task,
|
|
||||||
exitCode: 0,
|
|
||||||
messages: [],
|
|
||||||
stderr: "",
|
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
||||||
model: agent.model,
|
|
||||||
step,
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitUpdate = () => {
|
|
||||||
if (onUpdate) {
|
|
||||||
onUpdate({
|
|
||||||
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
|
||||||
details: makeDetails([currentResult]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (agent.systemPrompt.trim()) {
|
|
||||||
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
|
||||||
tmpPromptDir = tmp.dir;
|
|
||||||
tmpPromptPath = tmp.filePath;
|
|
||||||
args.push("--append-system-prompt", tmpPromptPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(`Task: ${task}`);
|
|
||||||
let wasAborted = false;
|
|
||||||
|
|
||||||
const exitCode = await new Promise<number>((resolve) => {
|
|
||||||
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
const processLine = (line: string) => {
|
|
||||||
if (!line.trim()) return;
|
|
||||||
let event: any;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(line);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "message_end" && event.message) {
|
|
||||||
const msg = event.message as Message;
|
|
||||||
currentResult.messages.push(msg);
|
|
||||||
|
|
||||||
if (msg.role === "assistant") {
|
|
||||||
currentResult.usage.turns++;
|
|
||||||
const usage = msg.usage;
|
|
||||||
if (usage) {
|
|
||||||
currentResult.usage.input += usage.input || 0;
|
|
||||||
currentResult.usage.output += usage.output || 0;
|
|
||||||
currentResult.usage.cacheRead += usage.cacheRead || 0;
|
|
||||||
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
|
|
||||||
currentResult.usage.cost += usage.cost?.total || 0;
|
|
||||||
currentResult.usage.contextTokens = usage.totalTokens || 0;
|
|
||||||
}
|
|
||||||
if (!currentResult.model && msg.model) currentResult.model = msg.model;
|
|
||||||
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
|
|
||||||
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
|
|
||||||
}
|
|
||||||
emitUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "tool_result_end" && event.message) {
|
|
||||||
currentResult.messages.push(event.message as Message);
|
|
||||||
emitUpdate();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
proc.stdout.on("data", (data) => {
|
|
||||||
buffer += data.toString();
|
|
||||||
const lines = buffer.split("\n");
|
|
||||||
buffer = lines.pop() || "";
|
|
||||||
for (const line of lines) processLine(line);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.stderr.on("data", (data) => {
|
|
||||||
currentResult.stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("close", (code) => {
|
|
||||||
if (buffer.trim()) processLine(buffer);
|
|
||||||
resolve(code ?? 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
proc.on("error", () => {
|
|
||||||
resolve(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (signal) {
|
|
||||||
const killProc = () => {
|
|
||||||
wasAborted = true;
|
|
||||||
proc.kill("SIGTERM");
|
|
||||||
setTimeout(() => {
|
|
||||||
if (!proc.killed) proc.kill("SIGKILL");
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
if (signal.aborted) killProc();
|
|
||||||
else signal.addEventListener("abort", killProc, { once: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
currentResult.exitCode = exitCode;
|
|
||||||
if (wasAborted) throw new Error("Subagent was aborted");
|
|
||||||
return currentResult;
|
|
||||||
} finally {
|
|
||||||
if (tmpPromptPath)
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(tmpPromptPath);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
if (tmpPromptDir)
|
|
||||||
try {
|
|
||||||
fs.rmdirSync(tmpPromptDir);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskItem = Type.Object({
|
|
||||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
||||||
task: Type.String({ description: "Task to delegate to the agent" }),
|
|
||||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const ChainItem = Type.Object({
|
|
||||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
|
||||||
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
|
||||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
|
||||||
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
|
||||||
default: "user",
|
|
||||||
});
|
|
||||||
|
|
||||||
const SubagentParams = Type.Object({
|
|
||||||
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
|
|
||||||
task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
|
|
||||||
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
|
||||||
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
|
|
||||||
agentScope: Type.Optional(AgentScopeSchema),
|
|
||||||
confirmProjectAgents: Type.Optional(
|
|
||||||
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
|
|
||||||
),
|
|
||||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
|
||||||
pi.registerTool({
|
|
||||||
name: "subagent",
|
|
||||||
label: "Subagent",
|
|
||||||
description: [
|
|
||||||
"Delegate tasks to specialized subagents with isolated context.",
|
|
||||||
"Modes: single (agent + task), parallel (tasks array), chain (sequential with {previous} placeholder).",
|
|
||||||
'Default agent scope is "user" (from ~/.pi/agent/agents).',
|
|
||||||
'To enable project-local agents in .pi/agents, set agentScope: "both" (or "project").',
|
|
||||||
].join(" "),
|
|
||||||
parameters: SubagentParams,
|
|
||||||
|
|
||||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
||||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
|
||||||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
|
||||||
const agents = discovery.agents;
|
|
||||||
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
|
||||||
|
|
||||||
const hasChain = (params.chain?.length ?? 0) > 0;
|
|
||||||
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
|
||||||
const hasSingle = Boolean(params.agent && params.task);
|
|
||||||
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
|
||||||
|
|
||||||
const makeDetails =
|
|
||||||
(mode: "single" | "parallel" | "chain") =>
|
|
||||||
(results: SingleResult[]): SubagentDetails => ({
|
|
||||||
mode,
|
|
||||||
agentScope,
|
|
||||||
projectAgentsDir: discovery.projectAgentsDir,
|
|
||||||
results,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (modeCount !== 1) {
|
|
||||||
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Invalid parameters. Provide exactly one mode.\nAvailable agents: ${available}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: makeDetails("single")([]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
|
|
||||||
const requestedAgentNames = new Set<string>();
|
|
||||||
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
|
||||||
if (params.tasks) for (const t of params.tasks) requestedAgentNames.add(t.agent);
|
|
||||||
if (params.agent) requestedAgentNames.add(params.agent);
|
|
||||||
|
|
||||||
const projectAgentsRequested = Array.from(requestedAgentNames)
|
|
||||||
.map((name) => agents.find((a) => a.name === name))
|
|
||||||
.filter((a): a is AgentConfig => a?.source === "project");
|
|
||||||
|
|
||||||
if (projectAgentsRequested.length > 0) {
|
|
||||||
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
|
||||||
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
|
||||||
const ok = await ctx.ui.confirm(
|
|
||||||
"Run project-local agents?",
|
|
||||||
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Only continue for trusted repositories.`,
|
|
||||||
);
|
|
||||||
if (!ok)
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: "Canceled: project-local agents not approved." }],
|
|
||||||
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.chain && params.chain.length > 0) {
|
|
||||||
const results: SingleResult[] = [];
|
|
||||||
let previousOutput = "";
|
|
||||||
|
|
||||||
for (let i = 0; i < params.chain.length; i++) {
|
|
||||||
const step = params.chain[i];
|
|
||||||
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
|
||||||
|
|
||||||
// Create update callback that includes all previous results
|
|
||||||
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
|
||||||
? (partial) => {
|
|
||||||
// Combine completed results with current streaming result
|
|
||||||
const currentResult = partial.details?.results[0];
|
|
||||||
if (currentResult) {
|
|
||||||
const allResults = [...results, currentResult];
|
|
||||||
onUpdate({
|
|
||||||
content: partial.content,
|
|
||||||
details: makeDetails("chain")(allResults),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const result = await runSingleAgent(
|
|
||||||
ctx.cwd,
|
|
||||||
agents,
|
|
||||||
step.agent,
|
|
||||||
taskWithContext,
|
|
||||||
step.cwd,
|
|
||||||
i + 1,
|
|
||||||
signal,
|
|
||||||
chainUpdate,
|
|
||||||
makeDetails("chain"),
|
|
||||||
);
|
|
||||||
results.push(result);
|
|
||||||
|
|
||||||
const isError =
|
|
||||||
result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
||||||
if (isError) {
|
|
||||||
const errorMsg =
|
|
||||||
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
|
||||||
details: makeDetails("chain")(results),
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
previousOutput = getFinalOutput(result.messages);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
|
|
||||||
details: makeDetails("chain")(results),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.tasks && params.tasks.length > 0) {
|
|
||||||
if (params.tasks.length > MAX_PARALLEL_TASKS)
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: makeDetails("parallel")([]),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track all results for streaming updates
|
|
||||||
const allResults: SingleResult[] = new Array(params.tasks.length);
|
|
||||||
|
|
||||||
// Initialize placeholder results
|
|
||||||
for (let i = 0; i < params.tasks.length; i++) {
|
|
||||||
allResults[i] = {
|
|
||||||
agent: params.tasks[i].agent,
|
|
||||||
agentSource: "unknown",
|
|
||||||
task: params.tasks[i].task,
|
|
||||||
exitCode: -1, // -1 = still running
|
|
||||||
messages: [],
|
|
||||||
stderr: "",
|
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const emitParallelUpdate = () => {
|
|
||||||
if (onUpdate) {
|
|
||||||
const running = allResults.filter((r) => r.exitCode === -1).length;
|
|
||||||
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
|
||||||
onUpdate({
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` },
|
|
||||||
],
|
|
||||||
details: makeDetails("parallel")([...allResults]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (t, index) => {
|
|
||||||
const result = await runSingleAgent(
|
|
||||||
ctx.cwd,
|
|
||||||
agents,
|
|
||||||
t.agent,
|
|
||||||
t.task,
|
|
||||||
t.cwd,
|
|
||||||
undefined,
|
|
||||||
signal,
|
|
||||||
// Per-task update callback
|
|
||||||
(partial) => {
|
|
||||||
if (partial.details?.results[0]) {
|
|
||||||
allResults[index] = partial.details.results[0];
|
|
||||||
emitParallelUpdate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
makeDetails("parallel"),
|
|
||||||
);
|
|
||||||
allResults[index] = result;
|
|
||||||
emitParallelUpdate();
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.exitCode === 0).length;
|
|
||||||
const summaries = results.map((r) => {
|
|
||||||
const output = getFinalOutput(r.messages);
|
|
||||||
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
|
|
||||||
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
details: makeDetails("parallel")(results),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.agent && params.task) {
|
|
||||||
const result = await runSingleAgent(
|
|
||||||
ctx.cwd,
|
|
||||||
agents,
|
|
||||||
params.agent,
|
|
||||||
params.task,
|
|
||||||
params.cwd,
|
|
||||||
undefined,
|
|
||||||
signal,
|
|
||||||
onUpdate,
|
|
||||||
makeDetails("single"),
|
|
||||||
);
|
|
||||||
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
|
||||||
if (isError) {
|
|
||||||
const errorMsg =
|
|
||||||
result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
|
|
||||||
details: makeDetails("single")([result]),
|
|
||||||
isError: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
|
||||||
details: makeDetails("single")([result]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
|
|
||||||
return {
|
|
||||||
content: [{ type: "text", text: `Invalid parameters. Available agents: ${available}` }],
|
|
||||||
details: makeDetails("single")([]),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
renderCall(args, theme) {
|
|
||||||
const scope: AgentScope = args.agentScope ?? "user";
|
|
||||||
if (args.chain && args.chain.length > 0) {
|
|
||||||
let text =
|
|
||||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
|
||||||
theme.fg("accent", `chain (${args.chain.length} steps)`) +
|
|
||||||
theme.fg("muted", ` [${scope}]`);
|
|
||||||
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
|
||||||
const step = args.chain[i];
|
|
||||||
// Clean up {previous} placeholder for display
|
|
||||||
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
|
||||||
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
|
||||||
text +=
|
|
||||||
"\n " +
|
|
||||||
theme.fg("muted", `${i + 1}.`) +
|
|
||||||
" " +
|
|
||||||
theme.fg("accent", step.agent) +
|
|
||||||
theme.fg("dim", ` ${preview}`);
|
|
||||||
}
|
|
||||||
if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
}
|
|
||||||
if (args.tasks && args.tasks.length > 0) {
|
|
||||||
let text =
|
|
||||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
|
||||||
theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
|
||||||
theme.fg("muted", ` [${scope}]`);
|
|
||||||
for (const t of args.tasks.slice(0, 3)) {
|
|
||||||
const preview = t.task.length > 40 ? `${t.task.slice(0, 40)}...` : t.task;
|
|
||||||
text += `\n ${theme.fg("accent", t.agent)}${theme.fg("dim", ` ${preview}`)}`;
|
|
||||||
}
|
|
||||||
if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
}
|
|
||||||
const agentName = args.agent || "...";
|
|
||||||
const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
|
|
||||||
let text =
|
|
||||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
|
||||||
theme.fg("accent", agentName) +
|
|
||||||
theme.fg("muted", ` [${scope}]`);
|
|
||||||
text += `\n ${theme.fg("dim", preview)}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
},
|
|
||||||
|
|
||||||
renderResult(result, { expanded }, theme) {
|
|
||||||
const details = result.details as SubagentDetails | undefined;
|
|
||||||
if (!details || details.results.length === 0) {
|
|
||||||
const text = result.content[0];
|
|
||||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mdTheme = getMarkdownTheme();
|
|
||||||
|
|
||||||
const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
|
|
||||||
const toShow = limit ? items.slice(-limit) : items;
|
|
||||||
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
|
||||||
let text = "";
|
|
||||||
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
|
||||||
for (const item of toShow) {
|
|
||||||
if (item.type === "text") {
|
|
||||||
const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
|
|
||||||
text += `${theme.fg("toolOutput", preview)}\n`;
|
|
||||||
} else {
|
|
||||||
text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return text.trimEnd();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (details.mode === "single" && details.results.length === 1) {
|
|
||||||
const r = details.results[0];
|
|
||||||
const isError = r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
|
||||||
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
|
||||||
const displayItems = getDisplayItems(r.messages);
|
|
||||||
const finalOutput = getFinalOutput(r.messages);
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
const container = new Container();
|
|
||||||
let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
|
|
||||||
if (isError && r.stopReason) header += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
||||||
container.addChild(new Text(header, 0, 0));
|
|
||||||
if (isError && r.errorMessage)
|
|
||||||
container.addChild(new Text(theme.fg("error", `Error: ${r.errorMessage}`), 0, 0));
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
|
||||||
container.addChild(new Text(theme.fg("dim", r.task), 0, 0));
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
|
||||||
if (displayItems.length === 0 && !finalOutput) {
|
|
||||||
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
|
||||||
} else {
|
|
||||||
for (const item of displayItems) {
|
|
||||||
if (item.type === "toolCall")
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (finalOutput) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const usageStr = formatUsageStats(r.usage, r.model);
|
|
||||||
if (usageStr) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
|
||||||
}
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
|
|
||||||
if (isError && r.stopReason) text += ` ${theme.fg("error", `[${r.stopReason}]`)}`;
|
|
||||||
if (isError && r.errorMessage) text += `\n${theme.fg("error", `Error: ${r.errorMessage}`)}`;
|
|
||||||
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
||||||
else {
|
|
||||||
text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
|
|
||||||
if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
||||||
}
|
|
||||||
const usageStr = formatUsageStats(r.usage, r.model);
|
|
||||||
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggregateUsage = (results: SingleResult[]) => {
|
|
||||||
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
||||||
for (const r of results) {
|
|
||||||
total.input += r.usage.input;
|
|
||||||
total.output += r.usage.output;
|
|
||||||
total.cacheRead += r.usage.cacheRead;
|
|
||||||
total.cacheWrite += r.usage.cacheWrite;
|
|
||||||
total.cost += r.usage.cost;
|
|
||||||
total.turns += r.usage.turns;
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (details.mode === "chain") {
|
|
||||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
|
||||||
const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
const container = new Container();
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
icon +
|
|
||||||
" " +
|
|
||||||
theme.fg("toolTitle", theme.bold("chain ")) +
|
|
||||||
theme.fg("accent", `${successCount}/${details.results.length} steps`),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const r of details.results) {
|
|
||||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
||||||
const displayItems = getDisplayItems(r.messages);
|
|
||||||
const finalOutput = getFinalOutput(r.messages);
|
|
||||||
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
`${theme.fg("muted", `─── Step ${r.step}: `) + theme.fg("accent", r.agent)} ${rIcon}`,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
|
||||||
|
|
||||||
// Show tool calls
|
|
||||||
for (const item of displayItems) {
|
|
||||||
if (item.type === "toolCall") {
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show final output as markdown
|
|
||||||
if (finalOutput) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepUsage = formatUsageStats(r.usage, r.model);
|
|
||||||
if (stepUsage) container.addChild(new Text(theme.fg("dim", stepUsage), 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
||||||
if (usageStr) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
|
|
||||||
}
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapsed view
|
|
||||||
let text =
|
|
||||||
icon +
|
|
||||||
" " +
|
|
||||||
theme.fg("toolTitle", theme.bold("chain ")) +
|
|
||||||
theme.fg("accent", `${successCount}/${details.results.length} steps`);
|
|
||||||
for (const r of details.results) {
|
|
||||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
||||||
const displayItems = getDisplayItems(r.messages);
|
|
||||||
text += `\n\n${theme.fg("muted", `─── Step ${r.step}: `)}${theme.fg("accent", r.agent)} ${rIcon}`;
|
|
||||||
if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
|
||||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
|
||||||
}
|
|
||||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
||||||
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
|
|
||||||
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.mode === "parallel") {
|
|
||||||
const running = details.results.filter((r) => r.exitCode === -1).length;
|
|
||||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
|
||||||
const failCount = details.results.filter((r) => r.exitCode > 0).length;
|
|
||||||
const isRunning = running > 0;
|
|
||||||
const icon = isRunning
|
|
||||||
? theme.fg("warning", "⏳")
|
|
||||||
: failCount > 0
|
|
||||||
? theme.fg("warning", "◐")
|
|
||||||
: theme.fg("success", "✓");
|
|
||||||
const status = isRunning
|
|
||||||
? `${successCount + failCount}/${details.results.length} done, ${running} running`
|
|
||||||
: `${successCount}/${details.results.length} tasks`;
|
|
||||||
|
|
||||||
if (expanded && !isRunning) {
|
|
||||||
const container = new Container();
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const r of details.results) {
|
|
||||||
const rIcon = r.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
|
||||||
const displayItems = getDisplayItems(r.messages);
|
|
||||||
const finalOutput = getFinalOutput(r.messages);
|
|
||||||
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(
|
|
||||||
new Text(`${theme.fg("muted", "─── ") + theme.fg("accent", r.agent)} ${rIcon}`, 0, 0),
|
|
||||||
);
|
|
||||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", r.task), 0, 0));
|
|
||||||
|
|
||||||
// Show tool calls
|
|
||||||
for (const item of displayItems) {
|
|
||||||
if (item.type === "toolCall") {
|
|
||||||
container.addChild(
|
|
||||||
new Text(
|
|
||||||
theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme)),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show final output as markdown
|
|
||||||
if (finalOutput) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskUsage = formatUsageStats(r.usage, r.model);
|
|
||||||
if (taskUsage) container.addChild(new Text(theme.fg("dim", taskUsage), 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
||||||
if (usageStr) {
|
|
||||||
container.addChild(new Spacer(1));
|
|
||||||
container.addChild(new Text(theme.fg("dim", `Total: ${usageStr}`), 0, 0));
|
|
||||||
}
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapsed view (or still running)
|
|
||||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
|
|
||||||
for (const r of details.results) {
|
|
||||||
const rIcon =
|
|
||||||
r.exitCode === -1
|
|
||||||
? theme.fg("warning", "⏳")
|
|
||||||
: r.exitCode === 0
|
|
||||||
? theme.fg("success", "✓")
|
|
||||||
: theme.fg("error", "✗");
|
|
||||||
const displayItems = getDisplayItems(r.messages);
|
|
||||||
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", r.agent)} ${rIcon}`;
|
|
||||||
if (displayItems.length === 0)
|
|
||||||
text += `\n${theme.fg("muted", r.exitCode === -1 ? "(running...)" : "(no output)")}`;
|
|
||||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
|
||||||
}
|
|
||||||
if (!isRunning) {
|
|
||||||
const usageStr = formatUsageStats(aggregateUsage(details.results));
|
|
||||||
if (usageStr) text += `\n\n${theme.fg("dim", `Total: ${usageStr}`)}`;
|
|
||||||
}
|
|
||||||
if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
|
||||||
return new Text(text, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = result.content[0];
|
|
||||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
---
|
|
||||||
description: Worker implements, reviewer reviews, worker applies feedback
|
|
||||||
---
|
|
||||||
Use the subagent tool with the chain parameter to execute this workflow:
|
|
||||||
|
|
||||||
1. First, use the "worker" agent to implement: $@
|
|
||||||
2. Then, use the "reviewer" agent to review the implementation from the previous step (use {previous} placeholder)
|
|
||||||
3. Finally, use the "worker" agent to apply the feedback from the review (use {previous} placeholder)
|
|
||||||
|
|
||||||
Execute this as a chain, passing output between steps via {previous}.
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
---
|
|
||||||
description: Full implementation workflow - scout gathers context, planner creates plan, worker implements
|
|
||||||
---
|
|
||||||
Use the subagent tool with the chain parameter to execute this workflow:
|
|
||||||
|
|
||||||
1. First, use the "scout" agent to find all code relevant to: $@
|
|
||||||
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
|
||||||
3. Finally, use the "worker" agent to implement the plan from the previous step (use {previous} placeholder)
|
|
||||||
|
|
||||||
Execute this as a chain, passing output between steps via {previous}.
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
---
|
|
||||||
description: Scout gathers context, planner creates implementation plan (no implementation)
|
|
||||||
---
|
|
||||||
Use the subagent tool with the chain parameter to execute this workflow:
|
|
||||||
|
|
||||||
1. First, use the "scout" agent to find all code relevant to: $@
|
|
||||||
2. Then, use the "planner" agent to create an implementation plan for "$@" using the context from the previous step (use {previous} placeholder)
|
|
||||||
|
|
||||||
Execute this as a chain, passing output between steps via {previous}. Do NOT implement - just return the plan.
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue