diff --git a/AGENTS.md b/AGENTS.md
index 79246aaba..b3a884c92 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,7 +10,6 @@ read README.md, then ask which module(s) to work on. Based on the answer, read t
- packages/agent/README.md
- packages/coding-agent/README.md
- packages/mom/README.md
-- packages/pods/README.md
- packages/web-ui/README.md
## Code Quality
@@ -287,7 +286,7 @@ When reading issues:
When creating issues:
- Add `pkg:*` labels to indicate which package(s) the issue affects
- - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:pods`, `pkg:tui`, `pkg:web-ui`
+ - Available labels: `pkg:agent`, `pkg:ai`, `pkg:coding-agent`, `pkg:mom`, `pkg:tui`, `pkg:web-ui`
- If an issue spans multiple packages, add all relevant labels
When closing issues via commit:
@@ -298,7 +297,7 @@ When closing issues via commit:
## Tools
- GitHub CLI for issues/PRs
-- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:tui, pkg:web-ui
+- Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:tui, pkg:web-ui
- TUI interaction: use tmux
## Style
diff --git a/README.md b/README.md
index 248ff35be..0ad9956d9 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,144 @@
-# Pi Monorepo
+
+
+
-Tools for building AI agents and managing LLM deployments.
+
+ AI coding agent for the terminal
+
+
+
+
+
+
+
+
+
+
+
+
+ Fork of badlogic/pi-mono by @mariozechner
+
+
+---
+
+## Fork Enhancements
+
+Features added on top of upstream pi:
+
+### MCP & Plugin System
+
+Full Model Context Protocol support with external tool integration:
+
+- Stdio and HTTP transports for connecting to MCP servers
+- Plugin CLI (`pi plugin install/enable/configure/doctor`)
+- Hot-loadable plugins from `~/.pi/plugins/` with npm/bun integration
+- 22 pre-built Exa MCP tools for web research, LinkedIn, fact-finding
+
+### LSP Tool (Language Server Protocol)
+
+IDE-like code intelligence via rust-analyzer and extensible to other languages:
+
+- File diagnostics with error/warning/info classification
+- Hover documentation, symbol references, implementations
+- Code actions and refactoring suggestions
+- Workspace-wide symbol search
+
+### Task Tool (Subagent System)
+
+Parallel execution framework with specialized agents:
+
+- **5 bundled agents**: explore, plan, browser, task, reviewer
+- User-level (`~/.pi/agent/agents/`) and project-level (`.pi/agents/`) custom agents
+- Concurrency-limited batch execution with progress tracking
+- Pre-defined commands: implement, architect-plan, implement-with-critic
+
+### Web Search & Fetch
+
+Multi-provider search and full-page scraping:
+
+- Anthropic and Perplexity search integration with caching
+- HTML-to-markdown conversion with link preservation
+- JavaScript rendering support, image handling
+
+### TUI Overhaul
+
+- **Welcome screen**: Logo, tips, recent sessions with selection
+- **Powerline footer**: Model, cwd, git branch/status, token usage, context %
+- **Hotkeys**: `?` displays shortcuts when editor empty
+- **Emergency terminal restore**: Crash handlers prevent terminal corruption
+
+### Git Context
+
+System prompt includes repo awareness:
+
+- Current branch, main branch auto-detection
+- Git status snapshot (staged/unstaged/untracked)
+- Recent 5 commits summary
+
+### Bun Runtime
+
+Migrated from Node.js for native TypeScript:
+
+- Runs `.ts` files directly without build step
+- Faster CLI startup times
+- All 7 packages converted to Bun APIs
+
+### Additional Tools
+
+- **Ask Tool**: Interactive user questioning (211 lines)
+- **AST Tool**: Structural code analysis via ast-grep (271 lines)
+- **Replace Tool**: Find & replace across files (297 lines)
+
+---
## Packages
-| Package | Description |
-| ---------------------------------------------------------- | ---------------------------------------------------------------- |
-| **[@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-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-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
-| **[@mariozechner/pi-pods](packages/pods)** | CLI for managing vLLM deployments on GPU pods |
+| Package | Description |
+| ------------------------------------------------------ | ---------------------------------------------------------------- |
+| **[@oh-my-pi/pi-ai](packages/ai)** | Unified multi-provider LLM API (OpenAI, Anthropic, Google, etc.) |
+| **[@oh-my-pi/pi-agent-core](packages/agent)** | Agent runtime with tool calling and state management |
+| **[@oh-my-pi/pi-coding-agent](packages/coding-agent)** | Interactive coding agent CLI |
+| **[@oh-my-pi/pi-mom](packages/mom)** | Slack bot that delegates messages to the pi coding agent |
+| **[@oh-my-pi/pi-tui](packages/tui)** | Terminal UI library with differential rendering |
+| **[@oh-my-pi/pi-web-ui](packages/web-ui)** | Web components for AI chat interfaces |
+
+---
## Development
### Setup
```bash
-bun install # Install all dependencies
-bun run build # Build all packages
-bun run check # Lint, format, and type check
+bun run dev:install # Install deps and link all packages
+bun run build # Build all packages
+bun run check # Lint, format, and type check
```
-> **Note:** `bun run check` requires `bun run build` to be run first. The web-ui package uses `tsc` which needs compiled `.d.ts` files from dependencies.
+> **Note:** `bun run check` requires `bun run build` first. The web-ui package uses `tsc` which needs compiled `.d.ts` files from dependencies.
-### CI
-
-GitHub Actions runs on push to `main` and on pull requests. The workflow runs `bun run check` and `bun test` for each package in parallel.
-
-**Do not add LLM API keys as secrets to this repository.** Tests that require LLM access use `describe.skipIf()` to skip when API keys are missing. This is intentional:
-
-- PRs from external contributors would have access to secrets in the CI environment
-- Malicious PR code could exfiltrate API keys
-- Tests that need LLM calls are skipped on CI and run locally by developers who have keys configured
-
-If you need to run LLM-dependent tests, run them locally with your own API keys.
-
-### Development
-
-Start watch builds for all packages:
+### Watch Mode
```bash
bun run dev
```
-Then run directly with Bun:
+Then run directly:
```bash
cd packages/coding-agent && bunx tsx src/cli.ts
-cd packages/pods && bunx tsx src/cli.ts
```
-### Versioning (Lockstep)
+### CI
-**All packages MUST always have the same version number.** Use these commands to bump versions:
+GitHub Actions runs on push to `main` and on pull requests. The workflow runs `bun run check` and `bun test` for each package in parallel.
+
+**Do not add LLM API keys as secrets.** Tests requiring LLM access use `describe.skipIf()` and run locally.
+
+---
+
+## Versioning
+
+All packages use lockstep versioning:
```bash
bun run version:patch # 0.7.5 -> 0.7.6
@@ -63,17 +146,11 @@ bun run version:minor # 0.7.5 -> 0.8.0
bun run version:major # 0.7.5 -> 1.0.0
```
-These commands:
+**Never manually edit version numbers.**
-1. Update all package versions to the same number
-2. Update inter-package dependency versions (e.g., `pi-agent` depends on `pi-ai@^0.7.7`)
-3. Update `bun.lockb`
+---
-**Note:** Version bumping uses `npm version -ws` since Bun doesn't yet have workspace version equivalent.
-
-**Never manually edit version numbers.** The lockstep system ensures consistency across the monorepo.
-
-### Publishing
+## Publishing
```bash
bun run release:patch # Bug fixes
@@ -81,16 +158,10 @@ bun run release:minor # New features
bun run release:major # Breaking changes
```
-This handles version bump, CHANGELOG updates, commit, tag, publish, and push.
+Requires an npm token with "Bypass 2FA on publish" enabled.
-**Note:** Publishing uses `npm publish` since Bun delegates to npm for publishing.
-
-**NPM Token Setup**: Requires a granular access token with "Bypass 2FA on publish" enabled.
-
-- Go to https://www.npmjs.com/settings/badlogic/tokens/
-- Create a new "Granular Access Token" with "Bypass 2FA on publish"
-- Set the token: `npm config set //registry.npmjs.org/:_authToken=YOUR_TOKEN`
+---
## License
-MIT
+MIT - Original work copyright Mario Zechner
diff --git a/assets/banner.html b/assets/banner.html
new file mode 100644
index 000000000..e1458cc76
--- /dev/null
+++ b/assets/banner.html
@@ -0,0 +1,183 @@
+
+
+
+
+
+ Oh My Pi Logo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
oh my PI
+
+
+ [
+ ai coding agent
+ ]
+
+
+
+
+
MCP · LSP · Subagents · Web Search
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/banner.png b/assets/banner.png
new file mode 100644
index 000000000..0481f8f09
Binary files /dev/null and b/assets/banner.png differ
diff --git a/assets/icon.svg b/assets/icon.svg
new file mode 100644
index 000000000..f1ccf2a08
--- /dev/null
+++ b/assets/icon.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/bun.lock b/bun.lock
index e6eef5818..51760f81d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -18,11 +18,11 @@
},
},
"packages/agent": {
- "name": "@mariozechner/pi-agent-core",
+ "name": "@oh-my-pi/pi-agent-core",
"version": "1.337.0",
"dependencies": {
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-tui": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-tui": "workspace:*",
},
"devDependencies": {
"@types/node": "^24.3.0",
@@ -30,7 +30,7 @@
},
},
"packages/ai": {
- "name": "@mariozechner/pi-ai",
+ "name": "@oh-my-pi/pi-ai",
"version": "1.337.0",
"bin": {
"pi-ai": "./src/cli.ts",
@@ -54,15 +54,17 @@
},
},
"packages/coding-agent": {
- "name": "@mariozechner/pi-coding-agent",
+ "name": "@oh-my-pi/pi-coding-agent",
"version": "1.337.0",
"bin": {
"pi": "src/cli.ts",
},
"dependencies": {
- "@mariozechner/pi-agent-core": "workspace:*",
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-tui": "workspace:*",
+ "@oh-my-pi/pi-agent-core": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-tui": "workspace:*",
+ "@sinclair/typebox": "^0.34.46",
+ "ajv": "^8.17.1",
"chalk": "^5.5.0",
"cli-highlight": "^2.1.11",
"diff": "^8.0.2",
@@ -81,16 +83,16 @@
},
},
"packages/mom": {
- "name": "@mariozechner/pi-mom",
+ "name": "@oh-my-pi/pi-mom",
"version": "1.337.0",
"bin": {
"mom": "src/main.ts",
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "^0.0.16",
- "@mariozechner/pi-agent-core": "workspace:*",
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-coding-agent": "workspace:*",
+ "@oh-my-pi/pi-agent-core": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-coding-agent": "workspace:*",
"@sinclair/typebox": "^0.34.0",
"@slack/socket-mode": "^2.0.0",
"@slack/web-api": "^7.0.0",
@@ -103,19 +105,8 @@
"@types/node": "^24.3.0",
},
},
- "packages/pods": {
- "name": "@mariozechner/pi",
- "version": "1.337.0",
- "bin": {
- "pi-pods": "dist/cli.js",
- },
- "dependencies": {
- "@mariozechner/pi-agent-core": "^0.30.2",
- "chalk": "^5.5.0",
- },
- },
"packages/tui": {
- "name": "@mariozechner/pi-tui",
+ "name": "@oh-my-pi/pi-tui",
"version": "1.337.0",
"dependencies": {
"@types/mime-types": "^2.1.4",
@@ -130,13 +121,13 @@
},
},
"packages/web-ui": {
- "name": "@mariozechner/pi-web-ui",
+ "name": "@oh-my-pi/pi-web-ui",
"version": "1.337.0",
"dependencies": {
"@lmstudio/sdk": "^1.5.0",
- "@mariozechner/pi-agent-core": "workspace:*",
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-tui": "workspace:*",
+ "@oh-my-pi/pi-agent-core": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-tui": "workspace:*",
"docx-preview": "^0.3.7",
"highlight.js": "^11.11.1",
"jszip": "^3.10.1",
@@ -160,8 +151,8 @@
"version": "1.19.1",
"dependencies": {
"@mariozechner/mini-lit": "^0.2.0",
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-web-ui": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-web-ui": "workspace:*",
"@tailwindcss/vite": "^4.1.17",
"lit": "^3.3.1",
"lucide": "^0.544.0",
@@ -278,20 +269,6 @@
"@mariozechner/mini-lit": ["@mariozechner/mini-lit@0.2.1", "", { "dependencies": { "@preact/signals-core": "^1.12.1", "class-variance-authority": "^0.7.1", "diff": "^8.0.2", "highlight.js": "^11.11.1", "html-parse-string": "^0.0.9", "katex": "^0.16.22", "lucide": "^0.544.0", "marked": "^16.3.0", "tailwind-merge": "^3.3.1", "tailwind-variants": "^3.1.1", "uhtml": "^5.0.9" }, "peerDependencies": { "lit": "^3.3.1" } }, "sha512-u300euLgCsDDlb8o2Wbz+55eSJga5X2vB58s9XBuFIr2Bi3iI+GMR7t/NYo/O6Vr6obXShXgYjR3SRUJVgo+kQ=="],
- "@mariozechner/pi": ["@mariozechner/pi@workspace:packages/pods"],
-
- "@mariozechner/pi-agent-core": ["@mariozechner/pi-agent-core@workspace:packages/agent"],
-
- "@mariozechner/pi-ai": ["@mariozechner/pi-ai@workspace:packages/ai"],
-
- "@mariozechner/pi-coding-agent": ["@mariozechner/pi-coding-agent@workspace:packages/coding-agent"],
-
- "@mariozechner/pi-mom": ["@mariozechner/pi-mom@workspace:packages/mom"],
-
- "@mariozechner/pi-tui": ["@mariozechner/pi-tui@workspace:packages/tui"],
-
- "@mariozechner/pi-web-ui": ["@mariozechner/pi-web-ui@workspace:packages/web-ui"],
-
"@mistralai/mistralai": ["@mistralai/mistralai@1.10.0", "", { "dependencies": { "zod": "^3.20.0", "zod-to-json-schema": "^3.24.1" } }, "sha512-tdIgWs4Le8vpvPiUEWne6tK0qbVc+jMenujnvTqOjogrJUsCSQhus0tHTU1avDDh5//Rq2dFgP9mWRAdIEoBqg=="],
"@napi-rs/canvas": ["@napi-rs/canvas@0.1.88", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.88", "@napi-rs/canvas-darwin-arm64": "0.1.88", "@napi-rs/canvas-darwin-x64": "0.1.88", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88", "@napi-rs/canvas-linux-arm64-gnu": "0.1.88", "@napi-rs/canvas-linux-arm64-musl": "0.1.88", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-gnu": "0.1.88", "@napi-rs/canvas-linux-x64-musl": "0.1.88", "@napi-rs/canvas-win32-arm64-msvc": "0.1.88", "@napi-rs/canvas-win32-x64-msvc": "0.1.88" } }, "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg=="],
@@ -318,6 +295,18 @@
"@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ=="],
+ "@oh-my-pi/pi-agent-core": ["@oh-my-pi/pi-agent-core@workspace:packages/agent"],
+
+ "@oh-my-pi/pi-ai": ["@oh-my-pi/pi-ai@workspace:packages/ai"],
+
+ "@oh-my-pi/pi-coding-agent": ["@oh-my-pi/pi-coding-agent@workspace:packages/coding-agent"],
+
+ "@oh-my-pi/pi-mom": ["@oh-my-pi/pi-mom@workspace:packages/mom"],
+
+ "@oh-my-pi/pi-tui": ["@oh-my-pi/pi-tui@workspace:packages/tui"],
+
+ "@oh-my-pi/pi-web-ui": ["@oh-my-pi/pi-web-ui@workspace:packages/web-ui"],
+
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -670,8 +659,6 @@
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
- "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
-
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@@ -894,8 +881,6 @@
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
- "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
-
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
@@ -986,8 +971,6 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
- "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
-
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"uhtml": ["uhtml@5.0.9", "", { "dependencies": { "@webreflection/alien-signals": "^0.3.2" } }, "sha512-qPyu3vGilaLe6zrjOCD/xezWEHLwdevxmbY3hzyhT25KBDF4F7YYW3YZcL3kylD/6dMoVISHjn8ggV3+9FY+5g=="],
@@ -1040,13 +1023,13 @@
"@mariozechner/mini-lit/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
- "@mariozechner/pi-agent-core/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
+ "@oh-my-pi/pi-agent-core/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
- "@mariozechner/pi-ai/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
+ "@oh-my-pi/pi-ai/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
- "@mariozechner/pi-coding-agent/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
+ "@oh-my-pi/pi-coding-agent/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
- "@mariozechner/pi-mom/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
+ "@oh-my-pi/pi-mom/@types/node": ["@types/node@24.10.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg=="],
"@parcel/watcher/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="],
@@ -1084,14 +1067,8 @@
"concurrently/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
- "ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
- "jwa/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
- "jws/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -1110,8 +1087,6 @@
"tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
- "tunnel-agent/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -1122,13 +1097,13 @@
"@lmstudio/sdk/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
- "@mariozechner/pi-agent-core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "@oh-my-pi/pi-agent-core/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
- "@mariozechner/pi-ai/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "@oh-my-pi/pi-ai/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
- "@mariozechner/pi-coding-agent/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "@oh-my-pi/pi-coding-agent/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
- "@mariozechner/pi-mom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+ "@oh-my-pi/pi-mom/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"@slack/logger/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -1138,8 +1113,6 @@
"@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
- "bl/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"cli-highlight/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -1164,20 +1137,14 @@
"string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
-
"wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
- "bl/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
"cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
- "tar-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
-
"cli-highlight/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
}
}
diff --git a/package.json b/package.json
index 41dda3103..2ce18e9ec 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"packages/web-ui/example"
],
"scripts": {
- "dev:install": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link && bun --cwd=packages/mom link && bun --cwd=packages/pods link",
+ "dev:install": "bun install && bun --cwd=packages/coding-agent link && bun --cwd=packages/ai link && bun --cwd=packages/mom link",
"build": "bun --cwd=packages/web-ui run build:css",
"dev": "bun --cwd=packages/web-ui run dev",
"check": "biome check --write . && bun --cwd=packages/web-ui run check",
diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md
index 991be7cb9..714f55ab2 100644
--- a/packages/agent/CHANGELOG.md
+++ b/packages/agent/CHANGELOG.md
@@ -11,6 +11,7 @@
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
- **Agent options renamed**:
+
- `transport` → removed (use `streamFn` instead)
- `messageTransformer` → `convertToLlm`
- `preprocessor` → `transformContext`
@@ -21,7 +22,7 @@
- **`UserMessageWithAttachments` and `Attachment` types removed**: Attachment handling is now the responsibility of the `convertToLlm` function.
-- **Agent loop moved from `@mariozechner/pi-ai`**: The `agentLoop`, `agentLoopContinue`, and related types have moved to this package. Import from `@mariozechner/pi-agent` instead.
+- **Agent loop moved from `@oh-my-pi/pi-ai`**: The `agentLoop`, `agentLoopContinue`, and related types have moved to this package. Import from `@oh-my-pi/pi-agent` instead.
### Added
diff --git a/packages/agent/README.md b/packages/agent/README.md
index ac66192a3..2fd4978ac 100644
--- a/packages/agent/README.md
+++ b/packages/agent/README.md
@@ -1,31 +1,31 @@
-# @mariozechner/pi-agent
+# @oh-my-pi/pi-agent
-Stateful agent with tool execution and event streaming. Built on `@mariozechner/pi-ai`.
+Stateful agent with tool execution and event streaming. Built on `@oh-my-pi/pi-ai`.
## Installation
```bash
-npm install @mariozechner/pi-agent
+npm install @oh-my-pi/pi-agent
```
## Quick Start
```typescript
-import { Agent } from "@mariozechner/pi-agent";
-import { getModel } from "@mariozechner/pi-ai";
+import { Agent } from "@oh-my-pi/pi-agent";
+import { getModel } from "@oh-my-pi/pi-ai";
const agent = new Agent({
- initialState: {
- systemPrompt: "You are a helpful assistant.",
- model: getModel("anthropic", "claude-sonnet-4-20250514"),
- },
+ initialState: {
+ systemPrompt: "You are a helpful assistant.",
+ model: getModel("anthropic", "claude-sonnet-4-20250514"),
+ },
});
agent.subscribe((event) => {
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
- // Stream just the new text chunk
- process.stdout.write(event.assistantMessageEvent.delta);
- }
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
+ // Stream just the new text chunk
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
});
await agent.prompt("Hello!");
@@ -36,6 +36,7 @@ await agent.prompt("Hello!");
### AgentMessage vs LLM Message
The agent works with `AgentMessage`, a flexible type that can include:
+
- Standard LLM messages (`user`, `assistant`, `toolResult`)
- Custom app-specific message types via declaration merging
@@ -112,18 +113,18 @@ The last message in context must be `user` or `toolResult` (not `assistant`).
### Event Types
-| Event | Description |
-|-------|-------------|
-| `agent_start` | Agent begins processing |
-| `agent_end` | Agent completes with all new messages |
-| `turn_start` | New turn begins (one LLM call + tool executions) |
-| `turn_end` | Turn completes with assistant message and tool results |
-| `message_start` | Any message begins (user, assistant, toolResult) |
-| `message_update` | **Assistant only.** Includes `assistantMessageEvent` with delta |
-| `message_end` | Message completes |
-| `tool_execution_start` | Tool begins |
-| `tool_execution_update` | Tool streams progress |
-| `tool_execution_end` | Tool completes |
+| Event | Description |
+| ----------------------- | --------------------------------------------------------------- |
+| `agent_start` | Agent begins processing |
+| `agent_end` | Agent completes with all new messages |
+| `turn_start` | New turn begins (one LLM call + tool executions) |
+| `turn_end` | Turn completes with assistant message and tool results |
+| `message_start` | Any message begins (user, assistant, toolResult) |
+| `message_update` | **Assistant only.** Includes `assistantMessageEvent` with delta |
+| `message_end` | Message completes |
+| `tool_execution_start` | Tool begins |
+| `tool_execution_update` | Tool streams progress |
+| `tool_execution_end` | Tool completes |
## Agent Options
@@ -162,15 +163,15 @@ const agent = new Agent({
```typescript
interface AgentState {
- systemPrompt: string;
- model: Model;
- thinkingLevel: ThinkingLevel;
- tools: AgentTool[];
- messages: AgentMessage[];
- isStreaming: boolean;
- streamMessage: AgentMessage | null; // Current partial during streaming
- pendingToolCalls: Set;
- error?: string;
+ systemPrompt: string;
+ model: Model;
+ thinkingLevel: ThinkingLevel;
+ tools: AgentTool[];
+ messages: AgentMessage[];
+ isStreaming: boolean;
+ streamMessage: AgentMessage | null; // Current partial during streaming
+ pendingToolCalls: Set;
+ error?: string;
}
```
@@ -185,9 +186,7 @@ Access via `agent.state`. During streaming, `streamMessage` contains the partial
await agent.prompt("Hello");
// With images
-await agent.prompt("What's in this image?", [
- { type: "image", data: base64Data, mimeType: "image/jpeg" }
-]);
+await agent.prompt("What's in this image?", [{ type: "image", data: base64Data, mimeType: "image/jpeg" }]);
// AgentMessage directly
await agent.prompt({ role: "user", content: "Hello", timestamp: Date.now() });
@@ -206,13 +205,13 @@ agent.setTools([myTool]);
agent.replaceMessages(newMessages);
agent.appendMessage(message);
agent.clearMessages();
-agent.reset(); // Clear everything
+agent.reset(); // Clear everything
```
### Control
```typescript
-agent.abort(); // Cancel current operation
+agent.abort(); // Cancel current operation
await agent.waitForIdle(); // Wait for completion
```
@@ -220,7 +219,7 @@ await agent.waitForIdle(); // Wait for completion
```typescript
const unsubscribe = agent.subscribe((event) => {
- console.log(event.type);
+ console.log(event.type);
});
unsubscribe();
```
@@ -234,13 +233,14 @@ agent.setQueueMode("one-at-a-time");
// While agent is running tools
agent.queueMessage({
- role: "user",
- content: "Stop! Do this instead.",
- timestamp: Date.now(),
+ role: "user",
+ content: "Stop! Do this instead.",
+ timestamp: Date.now(),
});
```
When queued messages are detected after a tool completes:
+
1. Remaining tools are skipped with error results
2. Queued message is injected
3. LLM responds to the interruption
@@ -250,10 +250,10 @@ When queued messages are detected after a tool completes:
Extend `AgentMessage` via declaration merging:
```typescript
-declare module "@mariozechner/pi-agent" {
- interface CustomAgentMessages {
- notification: { role: "notification"; text: string; timestamp: number };
- }
+declare module "@oh-my-pi/pi-agent" {
+ interface CustomAgentMessages {
+ notification: { role: "notification"; text: string; timestamp: number };
+ }
}
// Now valid
@@ -264,10 +264,11 @@ Handle custom types in `convertToLlm`:
```typescript
const agent = new Agent({
- convertToLlm: (messages) => messages.flatMap(m => {
- if (m.role === "notification") return []; // Filter out
- return [m];
- }),
+ convertToLlm: (messages) =>
+ messages.flatMap((m) => {
+ if (m.role === "notification") return []; // Filter out
+ return [m];
+ }),
});
```
@@ -279,23 +280,23 @@ Define tools using `AgentTool`:
import { Type } from "@sinclair/typebox";
const readFileTool: AgentTool = {
- name: "read_file",
- label: "Read File", // For UI display
- description: "Read a file's contents",
- parameters: Type.Object({
- path: Type.String({ description: "File path" }),
- }),
- execute: async (toolCallId, params, signal, onUpdate, context) => {
- const content = await fs.readFile(params.path, "utf-8");
+ name: "read_file",
+ label: "Read File", // For UI display
+ description: "Read a file's contents",
+ parameters: Type.Object({
+ path: Type.String({ description: "File path" }),
+ }),
+ execute: async (toolCallId, params, signal, onUpdate, context) => {
+ const content = await fs.readFile(params.path, "utf-8");
- // Optional: stream progress
- onUpdate?.({ content: [{ type: "text", text: "Reading..." }], details: {} });
+ // Optional: stream progress
+ onUpdate?.({ content: [{ type: "text", text: "Reading..." }], details: {} });
- return {
- content: [{ type: "text", text: content }],
- details: { path: params.path, size: content.length },
- };
- },
+ return {
+ content: [{ type: "text", text: content }],
+ details: { path: params.path, size: content.length },
+ };
+ },
};
agent.setTools([readFileTool]);
@@ -307,12 +308,12 @@ agent.setTools([readFileTool]);
```typescript
execute: async (toolCallId, params, signal, onUpdate) => {
- if (!fs.existsSync(params.path)) {
- throw new Error(`File not found: ${params.path}`);
- }
- // Return content only on success
- return { content: [{ type: "text", text: "..." }] };
-}
+ if (!fs.existsSync(params.path)) {
+ throw new Error(`File not found: ${params.path}`);
+ }
+ // Return content only on success
+ return { content: [{ type: "text", text: "..." }] };
+};
```
Thrown errors are caught by the agent and reported to the LLM as tool errors with `isError: true`.
@@ -322,15 +323,15 @@ Thrown errors are caught by the agent and reported to the LLM as tool errors wit
For browser apps that proxy through a backend:
```typescript
-import { Agent, streamProxy } from "@mariozechner/pi-agent";
+import { Agent, streamProxy } from "@oh-my-pi/pi-agent";
const agent = new Agent({
- streamFn: (model, context, options) =>
- streamProxy(model, context, {
- ...options,
- authToken: "...",
- proxyUrl: "https://your-server.com",
- }),
+ streamFn: (model, context, options) =>
+ streamProxy(model, context, {
+ ...options,
+ authToken: "...",
+ proxyUrl: "https://your-server.com",
+ }),
});
```
@@ -339,28 +340,28 @@ const agent = new Agent({
For direct control without the Agent class:
```typescript
-import { agentLoop, agentLoopContinue } from "@mariozechner/pi-agent";
+import { agentLoop, agentLoopContinue } from "@oh-my-pi/pi-agent";
const context: AgentContext = {
- systemPrompt: "You are helpful.",
- messages: [],
- tools: [],
+ systemPrompt: "You are helpful.",
+ messages: [],
+ tools: [],
};
const config: AgentLoopConfig = {
- model: getModel("openai", "gpt-4o"),
- convertToLlm: (msgs) => msgs.filter(m => ["user", "assistant", "toolResult"].includes(m.role)),
+ model: getModel("openai", "gpt-4o"),
+ convertToLlm: (msgs) => msgs.filter((m) => ["user", "assistant", "toolResult"].includes(m.role)),
};
const userMessage = { role: "user", content: "Hello", timestamp: Date.now() };
for await (const event of agentLoop([userMessage], context, config)) {
- console.log(event.type);
+ console.log(event.type);
}
// Continue from existing context
for await (const event of agentLoopContinue(context, config)) {
- console.log(event.type);
+ console.log(event.type);
}
```
diff --git a/packages/agent/package.json b/packages/agent/package.json
index 84d77ebd4..b3e328c04 100644
--- a/packages/agent/package.json
+++ b/packages/agent/package.json
@@ -1,5 +1,5 @@
{
- "name": "@mariozechner/pi-agent-core",
+ "name": "@oh-my-pi/pi-agent-core",
"version": "1.337.0",
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
"type": "module",
@@ -13,8 +13,8 @@
"test": "vitest --run"
},
"dependencies": {
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-tui": "workspace:*"
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-tui": "workspace:*"
},
"keywords": [
"ai",
@@ -27,7 +27,7 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "git+https://github.com/badlogic/pi-mono.git",
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/agent"
},
"engines": {
diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts
index 3ac56c528..b1a58b2c8 100644
--- a/packages/agent/src/agent-loop.ts
+++ b/packages/agent/src/agent-loop.ts
@@ -10,7 +10,7 @@ import {
streamSimple,
type ToolResultMessage,
validateToolArguments,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
import type {
AgentContext,
AgentEvent,
diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts
index b70cd656d..188fbc2ef 100644
--- a/packages/agent/src/agent.ts
+++ b/packages/agent/src/agent.ts
@@ -11,7 +11,7 @@ import {
type ReasoningEffort,
streamSimple,
type TextContent,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
import { agentLoop, agentLoopContinue } from "./agent-loop.js";
import type {
AgentContext,
diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts
index 1e37cf7a2..b147d758a 100644
--- a/packages/agent/src/proxy.ts
+++ b/packages/agent/src/proxy.ts
@@ -12,8 +12,8 @@ import {
type SimpleStreamOptions,
type StopReason,
type ToolCall,
-} from "@mariozechner/pi-ai";
-import { parseStreamingJson } from "@mariozechner/pi-ai/utils/json-parse";
+} from "@oh-my-pi/pi-ai";
+import { parseStreamingJson } from "@oh-my-pi/pi-ai/utils/json-parse";
// Create stream class matching ProxyMessageEventStream
class ProxyMessageEventStream extends EventStream {
diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts
index e7c1f0167..241599e95 100644
--- a/packages/agent/src/types.ts
+++ b/packages/agent/src/types.ts
@@ -8,7 +8,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
import type { Static, TSchema } from "@sinclair/typebox";
/** Stream function - can return sync or Promise for async config lookup */
@@ -101,7 +101,7 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi
*
* @example
* ```typescript
- * declare module "@mariozechner/agent" {
+ * declare module "@oh-my-pi/agent" {
* interface CustomAgentMessages {
* artifact: ArtifactMessage;
* notification: NotificationMessage;
diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts
index b82950389..357aee064 100644
--- a/packages/agent/test/agent-loop.test.ts
+++ b/packages/agent/test/agent-loop.test.ts
@@ -5,7 +5,7 @@ import {
type Message,
type Model,
type UserMessage,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import { agentLoop, agentLoopContinue } from "../src/agent-loop.js";
diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts
index 8fee033f0..910023fe8 100644
--- a/packages/agent/test/agent.test.ts
+++ b/packages/agent/test/agent.test.ts
@@ -1,4 +1,4 @@
-import { getModel } from "@mariozechner/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts
index 7996543a0..6e696eaa9 100644
--- a/packages/agent/test/e2e.test.ts
+++ b/packages/agent/test/e2e.test.ts
@@ -1,5 +1,5 @@
-import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
-import { getModel } from "@mariozechner/pi-ai";
+import type { AssistantMessage, Model, ToolResultMessage, UserMessage } from "@oh-my-pi/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import { describe, expect, it } from "vitest";
import { Agent } from "../src/index.js";
import { calculateTool } from "./utils/calculate.js";
diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md
index 88e15624a..5fd43c42a 100644
--- a/packages/ai/CHANGELOG.md
+++ b/packages/ai/CHANGELOG.md
@@ -12,7 +12,7 @@
### Breaking Changes
-- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@mariozechner/pi-agent-core`. Import from that package instead of `@mariozechner/pi-ai`.
+- **Agent API moved**: All agent functionality (`agentLoop`, `agentLoopContinue`, `AgentContext`, `AgentEvent`, `AgentTool`, `AgentToolResult`, etc.) has moved to `@oh-my-pi/pi-agent-core`. Import from that package instead of `@oh-my-pi/pi-ai`.
### Added
diff --git a/packages/ai/README.md b/packages/ai/README.md
index 89cdcfc47..1e9fb6dae 100644
--- a/packages/ai/README.md
+++ b/packages/ai/README.md
@@ -1,4 +1,4 @@
-# @mariozechner/pi-ai
+# @oh-my-pi/pi-ai
Unified LLM API with automatic model discovery, provider configuration, token and cost tracking, and simple context persistence and hand-off to other models mid-session.
@@ -20,80 +20,82 @@ Unified LLM API with automatic model discovery, provider configuration, token an
## Installation
```bash
-npm install @mariozechner/pi-ai
+npm install @oh-my-pi/pi-ai
```
## Quick Start
```typescript
-import { Type, getModel, stream, complete, Context, Tool, StringEnum } from '@mariozechner/pi-ai';
+import { Type, getModel, stream, complete, Context, Tool, StringEnum } from "@oh-my-pi/pi-ai";
// Fully typed with auto-complete support for both providers and models
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
// Define tools with TypeBox schemas for type safety and validation
-const tools: Tool[] = [{
- name: 'get_time',
- description: 'Get the current time',
- parameters: Type.Object({
- timezone: Type.Optional(Type.String({ description: 'Optional timezone (e.g., America/New_York)' }))
- })
-}];
+const tools: Tool[] = [
+ {
+ name: "get_time",
+ description: "Get the current time",
+ parameters: Type.Object({
+ timezone: Type.Optional(Type.String({ description: "Optional timezone (e.g., America/New_York)" })),
+ }),
+ },
+];
// Build a conversation context (easily serializable and transferable between models)
const context: Context = {
- systemPrompt: 'You are a helpful assistant.',
- messages: [{ role: 'user', content: 'What time is it?' }],
- tools
+ systemPrompt: "You are a helpful assistant.",
+ messages: [{ role: "user", content: "What time is it?" }],
+ tools,
};
// Option 1: Streaming with all event types
const s = stream(model, context);
for await (const event of s) {
- switch (event.type) {
- case 'start':
- console.log(`Starting with ${event.partial.model}`);
- break;
- case 'text_start':
- console.log('\n[Text started]');
- break;
- case 'text_delta':
- process.stdout.write(event.delta);
- break;
- case 'text_end':
- console.log('\n[Text ended]');
- break;
- case 'thinking_start':
- console.log('[Model is thinking...]');
- break;
- case 'thinking_delta':
- process.stdout.write(event.delta);
- break;
- case 'thinking_end':
- console.log('[Thinking complete]');
- break;
- case 'toolcall_start':
- console.log(`\n[Tool call started: index ${event.contentIndex}]`);
- break;
- case 'toolcall_delta':
- // Partial tool arguments are being streamed
- const partialCall = event.partial.content[event.contentIndex];
- if (partialCall.type === 'toolCall') {
- console.log(`[Streaming args for ${partialCall.name}]`);
- }
- break;
- case 'toolcall_end':
- console.log(`\nTool called: ${event.toolCall.name}`);
- console.log(`Arguments: ${JSON.stringify(event.toolCall.arguments)}`);
- break;
- case 'done':
- console.log(`\nFinished: ${event.reason}`);
- break;
- case 'error':
- console.error(`Error: ${event.error}`);
- break;
- }
+ switch (event.type) {
+ case "start":
+ console.log(`Starting with ${event.partial.model}`);
+ break;
+ case "text_start":
+ console.log("\n[Text started]");
+ break;
+ case "text_delta":
+ process.stdout.write(event.delta);
+ break;
+ case "text_end":
+ console.log("\n[Text ended]");
+ break;
+ case "thinking_start":
+ console.log("[Model is thinking...]");
+ break;
+ case "thinking_delta":
+ process.stdout.write(event.delta);
+ break;
+ case "thinking_end":
+ console.log("[Thinking complete]");
+ break;
+ case "toolcall_start":
+ console.log(`\n[Tool call started: index ${event.contentIndex}]`);
+ break;
+ case "toolcall_delta":
+ // Partial tool arguments are being streamed
+ const partialCall = event.partial.content[event.contentIndex];
+ if (partialCall.type === "toolCall") {
+ console.log(`[Streaming args for ${partialCall.name}]`);
+ }
+ break;
+ case "toolcall_end":
+ console.log(`\nTool called: ${event.toolCall.name}`);
+ console.log(`Arguments: ${JSON.stringify(event.toolCall.arguments)}`);
+ break;
+ case "done":
+ console.log(`\nFinished: ${event.reason}`);
+ break;
+ case "error":
+ console.error(`Error: ${event.error}`);
+ break;
+ }
}
// Get the final message after streaming, add it to the context
@@ -101,33 +103,34 @@ const finalMessage = await s.result();
context.messages.push(finalMessage);
// Handle tool calls if any
-const toolCalls = finalMessage.content.filter(b => b.type === 'toolCall');
+const toolCalls = finalMessage.content.filter((b) => b.type === "toolCall");
for (const call of toolCalls) {
- // Execute the tool
- const result = call.name === 'get_time'
- ? new Date().toLocaleString('en-US', {
- timeZone: call.arguments.timezone || 'UTC',
- dateStyle: 'full',
- timeStyle: 'long'
- })
- : 'Unknown tool';
+ // Execute the tool
+ const result =
+ call.name === "get_time"
+ ? new Date().toLocaleString("en-US", {
+ timeZone: call.arguments.timezone || "UTC",
+ dateStyle: "full",
+ timeStyle: "long",
+ })
+ : "Unknown tool";
- // Add tool result to context (supports text and images)
- context.messages.push({
- role: 'toolResult',
- toolCallId: call.id,
- toolName: call.name,
- content: [{ type: 'text', text: result }],
- isError: false,
- timestamp: Date.now()
- });
+ // Add tool result to context (supports text and images)
+ context.messages.push({
+ role: "toolResult",
+ toolCallId: call.id,
+ toolName: call.name,
+ content: [{ type: "text", text: result }],
+ isError: false,
+ timestamp: Date.now(),
+ });
}
// Continue if there were tool calls
if (toolCalls.length > 0) {
- const continuation = await complete(model, context);
- context.messages.push(continuation);
- console.log('After tool execution:', continuation.content);
+ const continuation = await complete(model, context);
+ context.messages.push(continuation);
+ console.log("After tool execution:", continuation.content);
}
console.log(`Total tokens: ${finalMessage.usage.input} in, ${finalMessage.usage.output} out`);
@@ -137,11 +140,11 @@ console.log(`Cost: $${finalMessage.usage.cost.total.toFixed(4)}`);
const response = await complete(model, context);
for (const block of response.content) {
- if (block.type === 'text') {
- console.log(block.text);
- } else if (block.type === 'toolCall') {
- console.log(`Tool: ${block.name}(${JSON.stringify(block.arguments)})`);
- }
+ if (block.type === "text") {
+ console.log(block.text);
+ } else if (block.type === "toolCall") {
+ console.log(`Tool: ${block.name}(${JSON.stringify(block.arguments)})`);
+ }
}
```
@@ -152,30 +155,30 @@ Tools enable LLMs to interact with external systems. This library uses TypeBox s
### Defining Tools
```typescript
-import { Type, Tool, StringEnum } from '@mariozechner/pi-ai';
+import { Type, Tool, StringEnum } from "@oh-my-pi/pi-ai";
// Define tool parameters with TypeBox
const weatherTool: Tool = {
- name: 'get_weather',
- description: 'Get current weather for a location',
- parameters: Type.Object({
- location: Type.String({ description: 'City name or coordinates' }),
- units: StringEnum(['celsius', 'fahrenheit'], { default: 'celsius' })
- })
+ name: "get_weather",
+ description: "Get current weather for a location",
+ parameters: Type.Object({
+ location: Type.String({ description: "City name or coordinates" }),
+ units: StringEnum(["celsius", "fahrenheit"], { default: "celsius" }),
+ }),
};
// Note: For Google API compatibility, use StringEnum helper instead of Type.Enum
// Type.Enum generates anyOf/const patterns that Google doesn't support
const bookMeetingTool: Tool = {
- name: 'book_meeting',
- description: 'Schedule a meeting',
- parameters: Type.Object({
- title: Type.String({ minLength: 1 }),
- startTime: Type.String({ format: 'date-time' }),
- endTime: Type.String({ format: 'date-time' }),
- attendees: Type.Array(Type.String({ format: 'email' }), { minItems: 1 })
- })
+ name: "book_meeting",
+ description: "Schedule a meeting",
+ parameters: Type.Object({
+ title: Type.String({ minLength: 1 }),
+ startTime: Type.String({ format: "date-time" }),
+ endTime: Type.String({ format: "date-time" }),
+ attendees: Type.Array(Type.String({ format: "email" }), { minItems: 1 }),
+ }),
};
```
@@ -184,46 +187,46 @@ const bookMeetingTool: Tool = {
Tool results use content blocks and can include both text and images:
```typescript
-import { readFileSync } from 'fs';
+import { readFileSync } from "fs";
const context: Context = {
- messages: [{ role: 'user', content: 'What is the weather in London?' }],
- tools: [weatherTool]
+ messages: [{ role: "user", content: "What is the weather in London?" }],
+ tools: [weatherTool],
};
const response = await complete(model, context);
// Check for tool calls in the response
for (const block of response.content) {
- if (block.type === 'toolCall') {
- // Execute your tool with the arguments
- // See "Validating Tool Arguments" section for validation
- const result = await executeWeatherApi(block.arguments);
+ if (block.type === "toolCall") {
+ // Execute your tool with the arguments
+ // See "Validating Tool Arguments" section for validation
+ const result = await executeWeatherApi(block.arguments);
- // Add tool result with text content
- context.messages.push({
- role: 'toolResult',
- toolCallId: block.id,
- toolName: block.name,
- content: [{ type: 'text', text: JSON.stringify(result) }],
- isError: false,
- timestamp: Date.now()
- });
- }
+ // Add tool result with text content
+ context.messages.push({
+ role: "toolResult",
+ toolCallId: block.id,
+ toolName: block.name,
+ content: [{ type: "text", text: JSON.stringify(result) }],
+ isError: false,
+ timestamp: Date.now(),
+ });
+ }
}
// Tool results can also include images (for vision-capable models)
-const imageBuffer = readFileSync('chart.png');
+const imageBuffer = readFileSync("chart.png");
context.messages.push({
- role: 'toolResult',
- toolCallId: 'tool_xyz',
- toolName: 'generate_chart',
- content: [
- { type: 'text', text: 'Generated chart showing temperature trends' },
- { type: 'image', data: imageBuffer.toString('base64'), mimeType: 'image/png' }
- ],
- isError: false,
- timestamp: Date.now()
+ role: "toolResult",
+ toolCallId: "tool_xyz",
+ toolName: "generate_chart",
+ content: [
+ { type: "text", text: "Generated chart showing temperature trends" },
+ { type: "image", data: imageBuffer.toString("base64"), mimeType: "image/png" },
+ ],
+ isError: false,
+ timestamp: Date.now(),
});
```
@@ -235,34 +238,35 @@ During streaming, tool call arguments are progressively parsed as they arrive. T
const s = stream(model, context);
for await (const event of s) {
- if (event.type === 'toolcall_delta') {
- const toolCall = event.partial.content[event.contentIndex];
+ if (event.type === "toolcall_delta") {
+ const toolCall = event.partial.content[event.contentIndex];
- // toolCall.arguments contains partially parsed JSON during streaming
- // This allows for progressive UI updates
- if (toolCall.type === 'toolCall' && toolCall.arguments) {
- // BE DEFENSIVE: arguments may be incomplete
- // Example: Show file path being written even before content is complete
- if (toolCall.name === 'write_file' && toolCall.arguments.path) {
- console.log(`Writing to: ${toolCall.arguments.path}`);
+ // toolCall.arguments contains partially parsed JSON during streaming
+ // This allows for progressive UI updates
+ if (toolCall.type === "toolCall" && toolCall.arguments) {
+ // BE DEFENSIVE: arguments may be incomplete
+ // Example: Show file path being written even before content is complete
+ if (toolCall.name === "write_file" && toolCall.arguments.path) {
+ console.log(`Writing to: ${toolCall.arguments.path}`);
- // Content might be partial or missing
- if (toolCall.arguments.content) {
- console.log(`Content preview: ${toolCall.arguments.content.substring(0, 100)}...`);
- }
- }
- }
- }
+ // Content might be partial or missing
+ if (toolCall.arguments.content) {
+ console.log(`Content preview: ${toolCall.arguments.content.substring(0, 100)}...`);
+ }
+ }
+ }
+ }
- if (event.type === 'toolcall_end') {
- // Here toolCall.arguments is complete (but not yet validated)
- const toolCall = event.toolCall;
- console.log(`Tool completed: ${toolCall.name}`, toolCall.arguments);
- }
+ if (event.type === "toolcall_end") {
+ // Here toolCall.arguments is complete (but not yet validated)
+ const toolCall = event.toolCall;
+ console.log(`Tool completed: ${toolCall.name}`, toolCall.arguments);
+ }
}
```
**Important notes about partial tool arguments:**
+
- During `toolcall_delta` events, `arguments` contains the best-effort parse of partial JSON
- Fields may be missing or incomplete - always check for existence before use
- String values may be truncated mid-word
@@ -278,32 +282,32 @@ When using `agentLoop`, tool arguments are automatically validated against your
When implementing your own tool execution loop with `stream()` or `complete()`, use `validateToolCall` to validate arguments before passing them to your tools:
```typescript
-import { stream, validateToolCall, Tool } from '@mariozechner/pi-ai';
+import { stream, validateToolCall, Tool } from "@oh-my-pi/pi-ai";
const tools: Tool[] = [weatherTool, calculatorTool];
const s = stream(model, { messages, tools });
for await (const event of s) {
- if (event.type === 'toolcall_end') {
- const toolCall = event.toolCall;
+ if (event.type === "toolcall_end") {
+ const toolCall = event.toolCall;
- try {
- // Validate arguments against the tool's schema (throws on invalid args)
- const validatedArgs = validateToolCall(tools, toolCall);
- const result = await executeMyTool(toolCall.name, validatedArgs);
- // ... add tool result to context
- } catch (error) {
- // Validation failed - return error as tool result so model can retry
- context.messages.push({
- role: 'toolResult',
- toolCallId: toolCall.id,
- toolName: toolCall.name,
- content: [{ type: 'text', text: error.message }],
- isError: true,
- timestamp: Date.now()
- });
- }
- }
+ try {
+ // Validate arguments against the tool's schema (throws on invalid args)
+ const validatedArgs = validateToolCall(tools, toolCall);
+ const result = await executeMyTool(toolCall.name, validatedArgs);
+ // ... add tool result to context
+ } catch (error) {
+ // Validation failed - return error as tool result so model can retry
+ context.messages.push({
+ role: "toolResult",
+ toolCallId: toolCall.id,
+ toolName: toolCall.name,
+ content: [{ type: "text", text: error.message }],
+ isError: true,
+ timestamp: Date.now(),
+ });
+ }
+ }
}
```
@@ -311,54 +315,56 @@ for await (const event of s) {
All streaming events emitted during assistant message generation:
-| Event Type | Description | Key Properties |
-|------------|-------------|----------------|
-| `start` | Stream begins | `partial`: Initial assistant message structure |
-| `text_start` | Text block starts | `contentIndex`: Position in content array |
-| `text_delta` | Text chunk received | `delta`: New text, `contentIndex`: Position |
-| `text_end` | Text block complete | `content`: Full text, `contentIndex`: Position |
-| `thinking_start` | Thinking block starts | `contentIndex`: Position in content array |
-| `thinking_delta` | Thinking chunk received | `delta`: New text, `contentIndex`: Position |
-| `thinking_end` | Thinking block complete | `content`: Full thinking, `contentIndex`: Position |
-| `toolcall_start` | Tool call begins | `contentIndex`: Position in content array |
-| `toolcall_delta` | Tool arguments streaming | `delta`: JSON chunk, `partial.content[contentIndex].arguments`: Partial parsed args |
-| `toolcall_end` | Tool call complete | `toolCall`: Complete validated tool call with `id`, `name`, `arguments` |
-| `done` | Stream complete | `reason`: Stop reason ("stop", "length", "toolUse"), `message`: Final assistant message |
-| `error` | Error occurred | `reason`: Error type ("error" or "aborted"), `error`: AssistantMessage with partial content |
+| Event Type | Description | Key Properties |
+| ---------------- | ------------------------ | ------------------------------------------------------------------------------------------- |
+| `start` | Stream begins | `partial`: Initial assistant message structure |
+| `text_start` | Text block starts | `contentIndex`: Position in content array |
+| `text_delta` | Text chunk received | `delta`: New text, `contentIndex`: Position |
+| `text_end` | Text block complete | `content`: Full text, `contentIndex`: Position |
+| `thinking_start` | Thinking block starts | `contentIndex`: Position in content array |
+| `thinking_delta` | Thinking chunk received | `delta`: New text, `contentIndex`: Position |
+| `thinking_end` | Thinking block complete | `content`: Full thinking, `contentIndex`: Position |
+| `toolcall_start` | Tool call begins | `contentIndex`: Position in content array |
+| `toolcall_delta` | Tool arguments streaming | `delta`: JSON chunk, `partial.content[contentIndex].arguments`: Partial parsed args |
+| `toolcall_end` | Tool call complete | `toolCall`: Complete validated tool call with `id`, `name`, `arguments` |
+| `done` | Stream complete | `reason`: Stop reason ("stop", "length", "toolUse"), `message`: Final assistant message |
+| `error` | Error occurred | `reason`: Error type ("error" or "aborted"), `error`: AssistantMessage with partial content |
## Image Input
Models with vision capabilities can process images. You can check if a model supports images via the `input` property. If you pass images to a non-vision model, they are silently ignored.
```typescript
-import { readFileSync } from 'fs';
-import { getModel, complete } from '@mariozechner/pi-ai';
+import { readFileSync } from "fs";
+import { getModel, complete } from "@oh-my-pi/pi-ai";
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
// Check if model supports images
-if (model.input.includes('image')) {
- console.log('Model supports vision');
+if (model.input.includes("image")) {
+ console.log("Model supports vision");
}
-const imageBuffer = readFileSync('image.png');
-const base64Image = imageBuffer.toString('base64');
+const imageBuffer = readFileSync("image.png");
+const base64Image = imageBuffer.toString("base64");
const response = await complete(model, {
- messages: [{
- role: 'user',
- content: [
- { type: 'text', text: 'What is in this image?' },
- { type: 'image', data: base64Image, mimeType: 'image/png' }
- ]
- }]
+ messages: [
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "What is in this image?" },
+ { type: "image", data: base64Image, mimeType: "image/png" },
+ ],
+ },
+ ],
});
// Access the response
for (const block of response.content) {
- if (block.type === 'text') {
- console.log(block.text);
- }
+ if (block.type === "text") {
+ console.log(block.text);
+ }
}
```
@@ -369,10 +375,10 @@ Many models support thinking/reasoning capabilities where they can show their in
### Unified Interface (streamSimple/completeSimple)
```typescript
-import { getModel, streamSimple, completeSimple } from '@mariozechner/pi-ai';
+import { getModel, streamSimple, completeSimple } from "@oh-my-pi/pi-ai";
// Many models across providers support thinking/reasoning
-const model = getModel('anthropic', 'claude-sonnet-4-20250514');
+const model = getModel("anthropic", "claude-sonnet-4-20250514");
// or getModel('openai', 'gpt-5-mini');
// or getModel('google', 'gemini-2.5-flash');
// or getModel('xai', 'grok-code-fast-1');
@@ -382,23 +388,27 @@ const model = getModel('anthropic', 'claude-sonnet-4-20250514');
// Check if model supports reasoning
if (model.reasoning) {
- console.log('Model supports reasoning/thinking');
+ console.log("Model supports reasoning/thinking");
}
// Use the simplified reasoning option
-const response = await completeSimple(model, {
- messages: [{ role: 'user', content: 'Solve: 2x + 5 = 13' }]
-}, {
- reasoning: 'medium' // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' (xhigh maps to high on non-OpenAI providers)
-});
+const response = await completeSimple(
+ model,
+ {
+ messages: [{ role: "user", content: "Solve: 2x + 5 = 13" }],
+ },
+ {
+ reasoning: "medium", // 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' (xhigh maps to high on non-OpenAI providers)
+ }
+);
// Access thinking and text blocks
for (const block of response.content) {
- if (block.type === 'thinking') {
- console.log('Thinking:', block.thinking);
- } else if (block.type === 'text') {
- console.log('Response:', block.text);
- }
+ if (block.type === "thinking") {
+ console.log("Thinking:", block.thinking);
+ } else if (block.type === "text") {
+ console.log("Response:", block.text);
+ }
}
```
@@ -407,29 +417,29 @@ for (const block of response.content) {
For fine-grained control, use the provider-specific options:
```typescript
-import { getModel, complete } from '@mariozechner/pi-ai';
+import { getModel, complete } from "@oh-my-pi/pi-ai";
// OpenAI Reasoning (o1, o3, gpt-5)
-const openaiModel = getModel('openai', 'gpt-5-mini');
+const openaiModel = getModel("openai", "gpt-5-mini");
await complete(openaiModel, context, {
- reasoningEffort: 'medium',
- reasoningSummary: 'detailed' // OpenAI Responses API only
+ reasoningEffort: "medium",
+ reasoningSummary: "detailed", // OpenAI Responses API only
});
// Anthropic Thinking (Claude Sonnet 4)
-const anthropicModel = getModel('anthropic', 'claude-sonnet-4-20250514');
+const anthropicModel = getModel("anthropic", "claude-sonnet-4-20250514");
await complete(anthropicModel, context, {
- thinkingEnabled: true,
- thinkingBudgetTokens: 8192 // Optional token limit
+ thinkingEnabled: true,
+ thinkingBudgetTokens: 8192, // Optional token limit
});
// Google Gemini Thinking
-const googleModel = getModel('google', 'gemini-2.5-flash');
+const googleModel = getModel("google", "gemini-2.5-flash");
await complete(googleModel, context, {
- thinking: {
- enabled: true,
- budgetTokens: 8192 // -1 for dynamic, 0 to disable
- }
+ thinking: {
+ enabled: true,
+ budgetTokens: 8192, // -1 for dynamic, 0 to disable
+ },
});
```
@@ -438,20 +448,20 @@ await complete(googleModel, context, {
When streaming, thinking content is delivered through specific events:
```typescript
-const s = streamSimple(model, context, { reasoning: 'high' });
+const s = streamSimple(model, context, { reasoning: "high" });
for await (const event of s) {
- switch (event.type) {
- case 'thinking_start':
- console.log('[Model started thinking]');
- break;
- case 'thinking_delta':
- process.stdout.write(event.delta); // Stream thinking content
- break;
- case 'thinking_end':
- console.log('\n[Thinking complete]');
- break;
- }
+ switch (event.type) {
+ case "thinking_start":
+ console.log("[Model started thinking]");
+ break;
+ case "thinking_delta":
+ process.stdout.write(event.delta); // Stream thinking content
+ break;
+ case "thinking_end":
+ console.log("\n[Thinking complete]");
+ break;
+ }
}
```
@@ -472,20 +482,20 @@ When a request ends with an error (including aborts and tool call validation err
```typescript
// In streaming
for await (const event of stream) {
- if (event.type === 'error') {
- // event.reason is either "error" or "aborted"
- // event.error is the AssistantMessage with partial content
- console.error(`Error (${event.reason}):`, event.error.errorMessage);
- console.log('Partial content:', event.error.content);
- }
+ if (event.type === "error") {
+ // event.reason is either "error" or "aborted"
+ // event.error is the AssistantMessage with partial content
+ console.error(`Error (${event.reason}):`, event.error.errorMessage);
+ console.log("Partial content:", event.error.content);
+ }
}
// The final message will have the error details
const message = await stream.result();
-if (message.stopReason === 'error' || message.stopReason === 'aborted') {
- console.error('Request failed:', message.errorMessage);
- // message.content contains any partial content received before the error
- // message.usage contains partial token counts and costs
+if (message.stopReason === "error" || message.stopReason === "aborted") {
+ console.error("Request failed:", message.errorMessage);
+ // message.content contains any partial content received before the error
+ // message.usage contains partial token counts and costs
}
```
@@ -494,35 +504,39 @@ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
The abort signal allows you to cancel in-progress requests. Aborted requests have `stopReason === 'aborted'`:
```typescript
-import { getModel, stream } from '@mariozechner/pi-ai';
+import { getModel, stream } from "@oh-my-pi/pi-ai";
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
const controller = new AbortController();
// Abort after 2 seconds
setTimeout(() => controller.abort(), 2000);
-const s = stream(model, {
- messages: [{ role: 'user', content: 'Write a long story' }]
-}, {
- signal: controller.signal
-});
+const s = stream(
+ model,
+ {
+ messages: [{ role: "user", content: "Write a long story" }],
+ },
+ {
+ signal: controller.signal,
+ }
+);
for await (const event of s) {
- if (event.type === 'text_delta') {
- process.stdout.write(event.delta);
- } else if (event.type === 'error') {
- // event.reason tells you if it was "error" or "aborted"
- console.log(`${event.reason === 'aborted' ? 'Aborted' : 'Error'}:`, event.error.errorMessage);
- }
+ if (event.type === "text_delta") {
+ process.stdout.write(event.delta);
+ } else if (event.type === "error") {
+ // event.reason tells you if it was "error" or "aborted"
+ console.log(`${event.reason === "aborted" ? "Aborted" : "Error"}:`, event.error.errorMessage);
+ }
}
// Get results (may be partial if aborted)
const response = await s.result();
-if (response.stopReason === 'aborted') {
- console.log('Request was aborted:', response.errorMessage);
- console.log('Partial content received:', response.content);
- console.log('Tokens used:', response.usage);
+if (response.stopReason === "aborted") {
+ console.log("Request was aborted:", response.errorMessage);
+ console.log("Partial content received:", response.content);
+ console.log("Tokens used:", response.usage);
}
```
@@ -532,9 +546,7 @@ Aborted messages can be added to the conversation context and continued in subse
```typescript
const context = {
- messages: [
- { role: 'user', content: 'Explain quantum computing in detail' }
- ]
+ messages: [{ role: "user", content: "Explain quantum computing in detail" }],
};
// First request gets aborted after 2 seconds
@@ -545,7 +557,7 @@ const partial = await complete(model, context, { signal: controller1.signal });
// Add the partial response to context
context.messages.push(partial);
-context.messages.push({ role: 'user', content: 'Please continue' });
+context.messages.push({ role: "user", content: "Please continue" });
// Continue the conversation
const continuation = await complete(model, context);
@@ -563,6 +575,7 @@ The library implements 4 API interfaces, each with its own streaming function an
### Providers and Models
A **provider** offers models through a specific API. For example:
+
- **Anthropic** models use the `anthropic-messages` API
- **Google** models use the `google-generative-ai` API
- **OpenAI** models use the `openai-responses` API
@@ -571,24 +584,24 @@ A **provider** offers models through a specific API. For example:
### Querying Providers and Models
```typescript
-import { getProviders, getModels, getModel } from '@mariozechner/pi-ai';
+import { getProviders, getModels, getModel } from "@oh-my-pi/pi-ai";
// Get all available providers
const providers = getProviders();
console.log(providers); // ['openai', 'anthropic', 'google', 'xai', 'groq', ...]
// Get all models from a provider (fully typed)
-const anthropicModels = getModels('anthropic');
+const anthropicModels = getModels("anthropic");
for (const model of anthropicModels) {
- console.log(`${model.id}: ${model.name}`);
- console.log(` API: ${model.api}`); // 'anthropic-messages'
- console.log(` Context: ${model.contextWindow} tokens`);
- console.log(` Vision: ${model.input.includes('image')}`);
- console.log(` Reasoning: ${model.reasoning}`);
+ console.log(`${model.id}: ${model.name}`);
+ console.log(` API: ${model.api}`); // 'anthropic-messages'
+ console.log(` Context: ${model.contextWindow} tokens`);
+ console.log(` Vision: ${model.input.includes("image")}`);
+ console.log(` Reasoning: ${model.reasoning}`);
}
// Get a specific model (both provider and model ID are auto-completed in IDEs)
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
console.log(`Using ${model.name} via ${model.api} API`);
```
@@ -597,60 +610,60 @@ console.log(`Using ${model.name} via ${model.api} API`);
You can create custom models for local inference servers or custom endpoints:
```typescript
-import { Model, stream } from '@mariozechner/pi-ai';
+import { Model, stream } from "@oh-my-pi/pi-ai";
// Example: Ollama using OpenAI-compatible API
-const ollamaModel: Model<'openai-completions'> = {
- id: 'llama-3.1-8b',
- name: 'Llama 3.1 8B (Ollama)',
- api: 'openai-completions',
- provider: 'ollama',
- baseUrl: 'http://localhost:11434/v1',
- reasoning: false,
- input: ['text'],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 128000,
- maxTokens: 32000
+const ollamaModel: Model<"openai-completions"> = {
+ id: "llama-3.1-8b",
+ name: "Llama 3.1 8B (Ollama)",
+ api: "openai-completions",
+ provider: "ollama",
+ baseUrl: "http://localhost:11434/v1",
+ reasoning: false,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 32000,
};
// Example: LiteLLM proxy with explicit compat settings
-const litellmModel: Model<'openai-completions'> = {
- id: 'gpt-4o',
- name: 'GPT-4o (via LiteLLM)',
- api: 'openai-completions',
- provider: 'litellm',
- baseUrl: 'http://localhost:4000/v1',
- reasoning: false,
- input: ['text', 'image'],
- cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 128000,
- maxTokens: 16384,
- compat: {
- supportsStore: false, // LiteLLM doesn't support the store field
- }
+const litellmModel: Model<"openai-completions"> = {
+ id: "gpt-4o",
+ name: "GPT-4o (via LiteLLM)",
+ api: "openai-completions",
+ provider: "litellm",
+ baseUrl: "http://localhost:4000/v1",
+ reasoning: false,
+ input: ["text", "image"],
+ cost: { input: 2.5, output: 10, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 16384,
+ compat: {
+ supportsStore: false, // LiteLLM doesn't support the store field
+ },
};
// Example: Custom endpoint with headers (bypassing Cloudflare bot detection)
-const proxyModel: Model<'anthropic-messages'> = {
- id: 'claude-sonnet-4',
- name: 'Claude Sonnet 4 (Proxied)',
- api: 'anthropic-messages',
- provider: 'custom-proxy',
- baseUrl: 'https://proxy.example.com/v1',
- reasoning: true,
- input: ['text', 'image'],
- cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
- contextWindow: 200000,
- maxTokens: 8192,
- headers: {
- 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
- 'X-Custom-Auth': 'bearer-token-here'
- }
+const proxyModel: Model<"anthropic-messages"> = {
+ id: "claude-sonnet-4",
+ name: "Claude Sonnet 4 (Proxied)",
+ api: "anthropic-messages",
+ provider: "custom-proxy",
+ baseUrl: "https://proxy.example.com/v1",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ contextWindow: 200000,
+ maxTokens: 8192,
+ headers: {
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
+ "X-Custom-Auth": "bearer-token-here",
+ },
};
// Use the custom model
const response = await stream(ollamaModel, context, {
- apiKey: 'dummy' // Ollama doesn't need a real key
+ apiKey: "dummy", // Ollama doesn't need a real key
});
```
@@ -660,10 +673,10 @@ The `openai-completions` API is implemented by many providers with minor differe
```typescript
interface OpenAICompat {
- supportsStore?: boolean; // Whether provider supports the `store` field (default: true)
- supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
- supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
- maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens)
+ supportsStore?: boolean; // Whether provider supports the `store` field (default: true)
+ supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true)
+ supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true)
+ maxTokensField?: "max_completion_tokens" | "max_tokens"; // Which field name to use (default: max_completion_tokens)
}
```
@@ -679,13 +692,13 @@ Models are typed by their API, ensuring type-safe options:
```typescript
// TypeScript knows this is an Anthropic model
-const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
+const claude = getModel("anthropic", "claude-sonnet-4-20250514");
// So these options are type-checked for AnthropicOptions
await stream(claude, context, {
- thinkingEnabled: true, // ✓ Valid for anthropic-messages
- thinkingBudgetTokens: 2048, // ✓ Valid for anthropic-messages
- // reasoningEffort: 'high' // ✗ TypeScript error: not valid for anthropic-messages
+ thinkingEnabled: true, // ✓ Valid for anthropic-messages
+ thinkingBudgetTokens: 2048, // ✓ Valid for anthropic-messages
+ // reasoningEffort: 'high' // ✗ TypeScript error: not valid for anthropic-messages
});
```
@@ -705,41 +718,43 @@ When messages from one provider are sent to a different provider, the library au
### Example: Multi-Provider Conversation
```typescript
-import { getModel, complete, Context } from '@mariozechner/pi-ai';
+import { getModel, complete, Context } from "@oh-my-pi/pi-ai";
// Start with Claude
-const claude = getModel('anthropic', 'claude-sonnet-4-20250514');
+const claude = getModel("anthropic", "claude-sonnet-4-20250514");
const context: Context = {
- messages: []
+ messages: [],
};
-context.messages.push({ role: 'user', content: 'What is 25 * 18?' });
+context.messages.push({ role: "user", content: "What is 25 * 18?" });
const claudeResponse = await complete(claude, context, {
- thinkingEnabled: true
+ thinkingEnabled: true,
});
context.messages.push(claudeResponse);
// Switch to GPT-5 - it will see Claude's thinking as tagged text
-const gpt5 = getModel('openai', 'gpt-5-mini');
-context.messages.push({ role: 'user', content: 'Is that calculation correct?' });
+const gpt5 = getModel("openai", "gpt-5-mini");
+context.messages.push({ role: "user", content: "Is that calculation correct?" });
const gptResponse = await complete(gpt5, context);
context.messages.push(gptResponse);
// Switch to Gemini
-const gemini = getModel('google', 'gemini-2.5-flash');
-context.messages.push({ role: 'user', content: 'What was the original question?' });
+const gemini = getModel("google", "gemini-2.5-flash");
+context.messages.push({ role: "user", content: "What was the original question?" });
const geminiResponse = await complete(gemini, context);
```
### Provider Compatibility
All providers can handle messages from other providers, including:
+
- Text content
- Tool calls and tool results (including images in tool results)
- Thinking/reasoning blocks (transformed to tagged text for cross-provider compatibility)
- Aborted messages with partial content
This enables flexible workflows where you can:
+
- Start with a fast model for initial responses
- Switch to a more capable model for complex reasoning
- Use specialized models for specific tasks
@@ -750,33 +765,31 @@ This enables flexible workflows where you can:
The `Context` object can be easily serialized and deserialized using standard JSON methods, making it simple to persist conversations, implement chat history, or transfer contexts between services:
```typescript
-import { Context, getModel, complete } from '@mariozechner/pi-ai';
+import { Context, getModel, complete } from "@oh-my-pi/pi-ai";
// Create and use a context
const context: Context = {
- systemPrompt: 'You are a helpful assistant.',
- messages: [
- { role: 'user', content: 'What is TypeScript?' }
- ]
+ systemPrompt: "You are a helpful assistant.",
+ messages: [{ role: "user", content: "What is TypeScript?" }],
};
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
const response = await complete(model, context);
context.messages.push(response);
// Serialize the entire context
const serialized = JSON.stringify(context);
-console.log('Serialized context size:', serialized.length, 'bytes');
+console.log("Serialized context size:", serialized.length, "bytes");
// Save to database, localStorage, file, etc.
-localStorage.setItem('conversation', serialized);
+localStorage.setItem("conversation", serialized);
// Later: deserialize and continue the conversation
-const restored: Context = JSON.parse(localStorage.getItem('conversation')!);
-restored.messages.push({ role: 'user', content: 'Tell me more about its type system' });
+const restored: Context = JSON.parse(localStorage.getItem("conversation")!);
+restored.messages.push({ role: "user", content: "Tell me more about its type system" });
// Continue with any model
-const newModel = getModel('anthropic', 'claude-3-5-haiku-20241022');
+const newModel = getModel("anthropic", "claude-3-5-haiku-20241022");
const continuation = await complete(newModel, restored);
```
@@ -787,16 +800,20 @@ const continuation = await complete(newModel, restored);
The library supports browser environments. You must pass the API key explicitly since environment variables are not available in browsers:
```typescript
-import { getModel, complete } from '@mariozechner/pi-ai';
+import { getModel, complete } from "@oh-my-pi/pi-ai";
// API key must be passed explicitly in browser
-const model = getModel('anthropic', 'claude-3-5-haiku-20241022');
+const model = getModel("anthropic", "claude-3-5-haiku-20241022");
-const response = await complete(model, {
- messages: [{ role: 'user', content: 'Hello!' }]
-}, {
- apiKey: 'your-api-key'
-});
+const response = await complete(
+ model,
+ {
+ messages: [{ role: "user", content: "Hello!" }],
+ },
+ {
+ apiKey: "your-api-key",
+ }
+);
```
> **Security Warning**: Exposing API keys in frontend code is dangerous. Anyone can extract and abuse your keys. Only use this approach for internal tools or demos. For production applications, use a backend proxy that keeps your API keys secure.
@@ -821,22 +838,22 @@ When set, the library automatically uses these keys:
```typescript
// Uses OPENAI_API_KEY from environment
-const model = getModel('openai', 'gpt-4o-mini');
+const model = getModel("openai", "gpt-4o-mini");
const response = await complete(model, context);
// Or override with explicit key
const response = await complete(model, context, {
- apiKey: 'sk-different-key'
+ apiKey: "sk-different-key",
});
```
### Checking Environment Variables
```typescript
-import { getEnvApiKey } from '@mariozechner/pi-ai';
+import { getEnvApiKey } from "@oh-my-pi/pi-ai";
// Check if an API key is set in environment variables
-const key = getEnvApiKey('openai'); // checks OPENAI_API_KEY
+const key = getEnvApiKey("openai"); // checks OPENAI_API_KEY
```
## OAuth Providers
@@ -853,9 +870,9 @@ Several providers require OAuth authentication instead of static API keys:
The quickest way to authenticate:
```bash
-npx @mariozechner/pi-ai login # interactive provider selection
-npx @mariozechner/pi-ai login anthropic # login to specific provider
-npx @mariozechner/pi-ai list # list available providers
+npx @oh-my-pi/pi-ai login # interactive provider selection
+npx @oh-my-pi/pi-ai login anthropic # login to specific provider
+npx @oh-my-pi/pi-ai list # list available providers
```
Credentials are saved to `auth.json` in the current directory.
@@ -866,42 +883,42 @@ The library provides login and token refresh functions. Credential storage is th
```typescript
import {
- // Login functions (return credentials, do not store)
- loginAnthropic,
- loginGitHubCopilot,
- loginGeminiCli,
- loginAntigravity,
-
- // Token management
- refreshOAuthToken, // (provider, credentials) => new credentials
- getOAuthApiKey, // (provider, credentialsMap) => { newCredentials, apiKey } | null
-
- // Types
- type OAuthProvider, // 'anthropic' | 'github-copilot' | 'google-gemini-cli' | 'google-antigravity'
- type OAuthCredentials,
-} from '@mariozechner/pi-ai';
+ // Login functions (return credentials, do not store)
+ loginAnthropic,
+ loginGitHubCopilot,
+ loginGeminiCli,
+ loginAntigravity,
+
+ // Token management
+ refreshOAuthToken, // (provider, credentials) => new credentials
+ getOAuthApiKey, // (provider, credentialsMap) => { newCredentials, apiKey } | null
+
+ // Types
+ type OAuthProvider, // 'anthropic' | 'github-copilot' | 'google-gemini-cli' | 'google-antigravity'
+ type OAuthCredentials,
+} from "@oh-my-pi/pi-ai";
```
### Login Flow Example
```typescript
-import { loginGitHubCopilot } from '@mariozechner/pi-ai';
-import { writeFileSync } from 'fs';
+import { loginGitHubCopilot } from "@oh-my-pi/pi-ai";
+import { writeFileSync } from "fs";
const credentials = await loginGitHubCopilot({
- onAuth: (url, instructions) => {
- console.log(`Open: ${url}`);
- if (instructions) console.log(instructions);
- },
- onPrompt: async (prompt) => {
- return await getUserInput(prompt.message);
- },
- onProgress: (message) => console.log(message)
+ onAuth: (url, instructions) => {
+ console.log(`Open: ${url}`);
+ if (instructions) console.log(instructions);
+ },
+ onPrompt: async (prompt) => {
+ return await getUserInput(prompt.message);
+ },
+ onProgress: (message) => console.log(message),
});
// Store credentials yourself
-const auth = { 'github-copilot': { type: 'oauth', ...credentials } };
-writeFileSync('auth.json', JSON.stringify(auth, null, 2));
+const auth = { "github-copilot": { type: "oauth", ...credentials } };
+writeFileSync("auth.json", JSON.stringify(auth, null, 2));
```
### Using OAuth Tokens
@@ -909,25 +926,29 @@ writeFileSync('auth.json', JSON.stringify(auth, null, 2));
Use `getOAuthApiKey()` to get an API key, automatically refreshing if expired:
```typescript
-import { getModel, complete, getOAuthApiKey } from '@mariozechner/pi-ai';
-import { readFileSync, writeFileSync } from 'fs';
+import { getModel, complete, getOAuthApiKey } from "@oh-my-pi/pi-ai";
+import { readFileSync, writeFileSync } from "fs";
// Load your stored credentials
-const auth = JSON.parse(readFileSync('auth.json', 'utf-8'));
+const auth = JSON.parse(readFileSync("auth.json", "utf-8"));
// Get API key (refreshes if expired)
-const result = await getOAuthApiKey('github-copilot', auth);
-if (!result) throw new Error('Not logged in');
+const result = await getOAuthApiKey("github-copilot", auth);
+if (!result) throw new Error("Not logged in");
// Save refreshed credentials
-auth['github-copilot'] = { type: 'oauth', ...result.newCredentials };
-writeFileSync('auth.json', JSON.stringify(auth, null, 2));
+auth["github-copilot"] = { type: "oauth", ...result.newCredentials };
+writeFileSync("auth.json", JSON.stringify(auth, null, 2));
// Use the API key
-const model = getModel('github-copilot', 'gpt-4o');
-const response = await complete(model, {
- messages: [{ role: 'user', content: 'Hello!' }]
-}, { apiKey: result.apiKey });
+const model = getModel("github-copilot", "gpt-4o");
+const response = await complete(
+ model,
+ {
+ messages: [{ role: "user", content: "Hello!" }],
+ },
+ { apiKey: result.apiKey }
+);
```
### Provider Notes
diff --git a/packages/ai/package.json b/packages/ai/package.json
index f7ef60301..ceef07233 100644
--- a/packages/ai/package.json
+++ b/packages/ai/package.json
@@ -1,5 +1,5 @@
{
- "name": "@mariozechner/pi-ai",
+ "name": "@oh-my-pi/pi-ai",
"version": "1.337.0",
"description": "Unified LLM API with automatic model discovery and provider configuration",
"type": "module",
@@ -46,7 +46,7 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "git+https://github.com/badlogic/pi-mono.git",
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/ai"
},
"engines": {
diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts
index 614456553..347fabe67 100755
--- a/packages/ai/src/cli.ts
+++ b/packages/ai/src/cli.ts
@@ -101,7 +101,7 @@ async function main(): Promise {
const command = args[0];
if (!command || command === "help" || command === "--help" || command === "-h") {
- console.log(`Usage: npx @mariozechner/pi-ai [provider]
+ console.log(`Usage: npx @oh-my-pi/pi-ai [provider]
Commands:
login [provider] Login to an OAuth provider
@@ -114,9 +114,9 @@ Providers:
google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS)
Examples:
- npx @mariozechner/pi-ai login # interactive provider selection
- npx @mariozechner/pi-ai login anthropic # login to specific provider
- npx @mariozechner/pi-ai list # list providers
+ npx @oh-my-pi/pi-ai login # interactive provider selection
+ npx @oh-my-pi/pi-ai login anthropic # login to specific provider
+ npx @oh-my-pi/pi-ai list # list providers
`);
return;
}
@@ -151,7 +151,7 @@ Examples:
if (!PROVIDERS.some((p) => p.id === provider)) {
console.error(`Unknown provider: ${provider}`);
- console.error(`Use 'npx @mariozechner/pi-ai list' to see available providers`);
+ console.error(`Use 'npx @oh-my-pi/pi-ai list' to see available providers`);
process.exit(1);
}
@@ -161,7 +161,7 @@ Examples:
}
console.error(`Unknown command: ${command}`);
- console.error(`Use 'npx @mariozechner/pi-ai --help' for usage`);
+ console.error(`Use 'npx @oh-my-pi/pi-ai --help' for usage`);
process.exit(1);
}
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index dda47046f..962aa6117 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -28,10 +28,12 @@ See [docs/session.md](docs/session.md) for the file format and `SessionManager`
The hooks API has been restructured with more granular events and better session access.
**Type renames:**
+
- `HookEventContext` → `HookContext`
- `HookCommandContext` is now a new interface extending `HookContext` with session control methods
**Event changes:**
+
- The monolithic `session` event is now split into granular events: `session_start`, `session_before_switch`, `session_switch`, `session_before_branch`, `session_branch`, `session_before_compact`, `session_compact`, `session_shutdown`
- `session_before_switch` and `session_switch` events now include `reason: "new" | "resume"` to distinguish between `/new` and `/resume`
- New `session_before_tree` and `session_tree` events for `/tree` navigation (hook can provide custom branch summary)
@@ -40,6 +42,7 @@ The hooks API has been restructured with more granular events and better session
- Session entries are no longer passed in events. Use `ctx.sessionManager.getEntries()` or `ctx.sessionManager.getBranch()` instead
**API changes:**
+
- `pi.send(text, attachments?)` → `pi.sendMessage(message, triggerTurn?)` (creates `CustomMessageEntry`)
- New `pi.appendEntry(customType, data?)` for hook state persistence (not in LLM context)
- New `pi.registerCommand(name, options)` for custom slash commands (handler receives `HookCommandContext`)
@@ -54,6 +57,7 @@ The hooks API has been restructured with more granular events and better session
- New `ctx.modelRegistry` and `ctx.model` for API key resolution
**HookCommandContext (slash commands only):**
+
- `ctx.waitForIdle()` - wait for agent to finish streaming
- `ctx.newSession(options?)` - create new sessions with optional setup callback
- `ctx.branch(entryId)` - branch from a specific entry
@@ -62,6 +66,7 @@ The hooks API has been restructured with more granular events and better session
These methods are only on `HookCommandContext` (not `HookContext`) because they can deadlock if called from event handlers that run inside the agent loop.
**Removed:**
+
- `hookTimeout` setting (hooks no longer have timeouts; use Ctrl+C to abort)
- `resolveApiKey` parameter (use `ctx.modelRegistry.getApiKey(model)`)
@@ -72,12 +77,14 @@ See [docs/hooks.md](docs/hooks.md) and [examples/hooks/](examples/hooks/) for th
The custom tools API has been restructured to mirror the hooks pattern with a context object.
**Type renames:**
+
- `CustomAgentTool` → `CustomTool`
- `ToolAPI` → `CustomToolAPI`
- `ToolContext` → `CustomToolContext`
- `ToolSessionEvent` → `CustomToolSessionEvent`
**Execute signature changed:**
+
```typescript
// Before (v0.30.2)
execute(toolCallId, params, signal, onUpdate)
@@ -87,11 +94,13 @@ execute(toolCallId, params, onUpdate, ctx, signal?)
```
The new `ctx: CustomToolContext` provides `sessionManager`, `modelRegistry`, `model`, and agent state methods:
+
- `ctx.isIdle()` - check if agent is streaming
- `ctx.hasQueuedMessages()` - check if user has queued messages (skip interactive prompts)
- `ctx.abort()` - abort current operation (fire-and-forget)
**Session event changes:**
+
- `CustomToolSessionEvent` now only has `reason` and `previousSessionFile`
- Session entries are no longer in the event. Use `ctx.sessionManager.getBranch()` or `ctx.sessionManager.getEntries()` to reconstruct state
- Reasons: `"start" | "switch" | "branch" | "tree" | "shutdown"` (no separate `"new"` reason; `/new` triggers `"switch"`)
@@ -102,13 +111,15 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex
### SDK Migration
**Type changes:**
+
- `CustomAgentTool` → `CustomTool`
- `AppMessage` → `AgentMessage`
- `sessionFile` returns `string | undefined` (was `string | null`)
- `model` returns `Model | undefined` (was `Model | null`)
-- `Attachment` type removed. Use `ImageContent` from `@mariozechner/pi-ai` instead. Add images directly to message content arrays.
+- `Attachment` type removed. Use `ImageContent` from `@oh-my-pi/pi-ai` instead. Add images directly to message content arrays.
**AgentSession API:**
+
- `branch(entryIndex: number)` → `branch(entryId: string)`
- `getUserMessagesForBranching()` returns `{ entryId, text }` instead of `{ entryIndex, text }`
- `reset()` → `newSession(options?)` where options has optional `parentSession` for lineage tracking
@@ -116,9 +127,11 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex
- New `navigateTree(targetId, options?)` for in-place tree navigation
**Hook integration:**
+
- New `sendHookMessage(message, triggerTurn?)` for hook message injection
**SessionManager API:**
+
- Method renames: `saveXXX()` → `appendXXX()` (e.g., `appendMessage`, `appendCompaction`)
- `branchInPlace()` → `branch()`
- `reset()` → `newSession(options?)` with optional `parentSession` for lineage tracking
@@ -136,10 +149,10 @@ See [docs/custom-tools.md](docs/custom-tools.md) and [examples/custom-tools/](ex
`ModelRegistry` is a new class that manages model discovery and API key resolution. It combines built-in models with custom models from `models.json` and resolves API keys via `AuthStorage`.
```typescript
-import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
+import { discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
-const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
-const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
+const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
+const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
// Get all models (built-in + custom)
const allModels = modelRegistry.getAll();
@@ -157,6 +170,7 @@ const apiKey = await modelRegistry.getApiKey(model);
This replaces the old `resolveApiKey` callback pattern. Hooks and custom tools access it via `ctx.modelRegistry`.
**Renamed exports:**
+
- `messageTransformer` → `convertToLlm`
- `SessionContext` alias `LoadedSession` removed
@@ -165,17 +179,21 @@ See [docs/sdk.md](docs/sdk.md) and [examples/sdk/](examples/sdk/) for the curren
### RPC Migration
**Session commands:**
+
- `reset` command → `new_session` command with optional `parentSession` field
**Branching commands:**
+
- `branch` command: `entryIndex` → `entryId`
- `get_branch_messages` response: `entryIndex` → `entryId`
**Type changes:**
+
- Messages are now `AgentMessage` (was `AppMessage`)
- `prompt` command: `attachments` field replaced with `images` field using `ImageContent` format
**Compaction events:**
+
- `auto_compaction_start` now includes `reason` field (`"threshold"` or `"overflow"`)
- `auto_compaction_end` now includes `willRetry` field
- `compact` response includes full `CompactionResult` (`summary`, `firstKeptEntryId`, `tokensBefore`, `details`)
@@ -185,6 +203,7 @@ See [docs/rpc.md](docs/rpc.md) for the current protocol.
### Structured Compaction
Compaction and branch summarization now use a structured output format:
+
- Clear sections: Goal, Progress, Key Information, File Operations
- File tracking: `readFiles` and `modifiedFiles` arrays in `details`, accumulated across compactions
- Conversations are serialized to text before summarization to prevent the model from "continuing" them
@@ -194,6 +213,7 @@ The `before_compact` and `before_tree` hook events allow custom compaction imple
### Interactive Mode
**`/tree` command:**
+
- Navigate the full session tree in-place
- Search by typing, page with ←/→
- Filter modes (Ctrl+O): default → no-tools → user-only → labeled-only → all
@@ -201,12 +221,14 @@ The `before_compact` and `before_tree` hook events allow custom compaction imple
- Selecting a branch switches context and optionally injects a summary of the abandoned branch
**Entry labels:**
+
- Bookmark any entry via `/tree` → select → `l`
- Labels appear in tree view and persist as `LabelEntry`
**Theme changes (breaking for custom themes):**
Custom themes must add these new color tokens or they will fail to load:
+
- `selectedBg`: background for selected/highlighted items in tree selector and other components
- `customMessageBg`: background for hook-injected messages (`CustomMessageEntry`)
- `customMessageText`: text color for hook messages
@@ -215,6 +237,7 @@ Custom themes must add these new color tokens or they will fail to load:
Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) for the full color list and copy values from the built-in dark/light themes.
**Settings:**
+
- `enabledModels`: allowlist models in `settings.json` (same format as `--models` CLI)
### Added
@@ -312,13 +335,14 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- **Credential storage refactored**: API keys and OAuth tokens are now stored in `~/.pi/agent/auth.json` instead of `oauth.json` and `settings.json`. Existing credentials are automatically migrated on first run. ([#296](https://github.com/badlogic/pi-mono/issues/296))
- **SDK API changes** ([#296](https://github.com/badlogic/pi-mono/issues/296)):
+
- Added `AuthStorage` class for credential management (API keys and OAuth tokens)
- Added `ModelRegistry` class for model discovery and API key resolution
- Added `discoverAuthStorage()` and `discoverModels()` discovery functions
- `createAgentSession()` now accepts `authStorage` and `modelRegistry` options
- Removed `configureOAuthStorage()`, `defaultGetApiKey()`, `findModel()`, `discoverAvailableModels()`
- Removed `getApiKey` callback option (use `AuthStorage.setRuntimeApiKey()` for runtime overrides)
- - Use `getModel()` from `@mariozechner/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models
+ - Use `getModel()` from `@oh-my-pi/pi-ai` for built-in models, `modelRegistry.find()` for custom models + built-in models
- See updated [SDK documentation](docs/sdk.md) and [README](README.md)
- **Settings changes**: Removed `apiKeys` from `settings.json`. Use `auth.json` instead. ([#296](https://github.com/badlogic/pi-mono/issues/296))
@@ -350,6 +374,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Added
- **Compaction hook improvements**: The `before_compact` session event now includes:
+
- `previousSummary`: Summary from the last compaction (if any), so hooks can preserve accumulated context
- `messagesToKeep`: Messages that will be kept after the summary (recent turns), in addition to `messagesToSummarize`
- `resolveApiKey`: Function to resolve API keys for any model (checks settings, OAuth, env vars)
@@ -522,7 +547,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Added
-- **OAuth and model config exports**: Scripts using `AgentSession` directly can now import `getAvailableModels`, `getApiKeyForModel`, `findModel`, `login`, `logout`, and `getOAuthProviders` from `@mariozechner/pi-coding-agent` to reuse OAuth token storage and model resolution. ([#245](https://github.com/badlogic/pi-mono/issues/245))
+- **OAuth and model config exports**: Scripts using `AgentSession` directly can now import `getAvailableModels`, `getApiKeyForModel`, `findModel`, `login`, `logout`, and `getOAuthProviders` from `@oh-my-pi/pi-coding-agent` to reuse OAuth token storage and model resolution. ([#245](https://github.com/badlogic/pi-mono/issues/245))
- **xhigh thinking level for gpt-5.2 models**: The thinking level selector and shift+tab cycling now show xhigh option for gpt-5.2 and gpt-5.2-codex models (in addition to gpt-5.1-codex-max). ([#236](https://github.com/badlogic/pi-mono/pull/236) by [@theBucky](https://github.com/theBucky))
@@ -548,7 +573,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- **Subagent orchestration example**: Added comprehensive custom tool example for spawning and orchestrating sub-agents with isolated context windows. Includes scout/planner/reviewer/worker agents and workflow commands for multi-agent pipelines. ([#215](https://github.com/badlogic/pi-mono/pull/215) by [@nicobailon](https://github.com/nicobailon))
-- **`getMarkdownTheme()` export**: Custom tools can now import `getMarkdownTheme()` from `@mariozechner/pi-coding-agent` to use the same markdown styling as the main UI.
+- **`getMarkdownTheme()` export**: Custom tools can now import `getMarkdownTheme()` from `@oh-my-pi/pi-coding-agent` to use the same markdown styling as the main UI.
- **`pi.exec()` signal and timeout support**: Custom tools and hooks can now pass `{ signal, timeout }` options to `pi.exec()` for cancellation and timeout handling. The result includes a `killed` flag when the process was terminated.
@@ -612,6 +637,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- Improved system prompt documentation section with clearer pointers to specific doc files for custom models, themes, skills, hooks, custom tools, and RPC.
- Cleaned up documentation:
+
- `theme.md`: Added missing color tokens (`thinkingXhigh`, `bashMode`)
- `skills.md`: Rewrote with better framing and examples
- `hooks.md`: Fixed timeout/error handling docs, added import aliases section
@@ -619,7 +645,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- `rpc.md`: Added missing `hook_error` event documentation
- `README.md`: Complete settings table, condensed philosophy section, standardized OAuth docs
-- Hooks loader now supports same import aliases as custom tools (`@sinclair/typebox`, `@mariozechner/pi-ai`, `@mariozechner/pi-tui`, `@mariozechner/pi-coding-agent`).
+- Hooks loader now supports same import aliases as custom tools (`@sinclair/typebox`, `@oh-my-pi/pi-ai`, `@oh-my-pi/pi-tui`, `@oh-my-pi/pi-coding-agent`).
### Breaking Changes
@@ -641,7 +667,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- Fixed TUI performance regression caused by Box component lacking render caching. Built-in tools now use Text directly (like v0.22.5), and Box has proper caching for custom tool rendering.
-- Fixed custom tools failing to load from `~/.pi/agent/tools/` when pi is installed globally. Module imports (`@sinclair/typebox`, `@mariozechner/pi-tui`, `@mariozechner/pi-ai`) are now resolved via aliases.
+- Fixed custom tools failing to load from `~/.pi/agent/tools/` when pi is installed globally. Module imports (`@sinclair/typebox`, `@oh-my-pi/pi-tui`, `@oh-my-pi/pi-ai`) are now resolved via aliases.
## [0.23.0] - 2025-12-17
@@ -681,7 +707,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
- **Tool output display**: When collapsed, tool output now shows the last N lines instead of the first N lines, making streaming output more useful.
-- Updated `@mariozechner/pi-ai` with X-Initiator header support for GitHub Copilot, ensuring agent calls are not deducted from quota. ([#200](https://github.com/badlogic/pi-mono/pull/200) by [@kim0](https://github.com/kim0))
+- Updated `@oh-my-pi/pi-ai` with X-Initiator header support for GitHub Copilot, ensuring agent calls are not deducted from quota. ([#200](https://github.com/badlogic/pi-mono/pull/200) by [@kim0](https://github.com/kim0))
### Fixed
@@ -693,7 +719,7 @@ Total color count increased from 46 to 50. See [docs/theme.md](docs/theme.md) fo
### Changed
-- Updated `@mariozechner/pi-ai` with interleaved thinking enabled by default for Anthropic Claude 4 models.
+- Updated `@oh-my-pi/pi-ai` with interleaved thinking enabled by default for Anthropic Claude 4 models.
## [0.22.1] - 2025-12-15
@@ -701,7 +727,7 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
### Changed
-- Updated `@mariozechner/pi-ai` with interleaved thinking support for Anthropic models.
+- Updated `@oh-my-pi/pi-ai` with interleaved thinking support for Anthropic models.
## [0.22.0] - 2025-12-15
diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md
index c84673483..291f96417 100644
--- a/packages/coding-agent/DEVELOPMENT.md
+++ b/packages/coding-agent/DEVELOPMENT.md
@@ -28,9 +28,9 @@ The coding-agent is structured into distinct layers:
▼
┌─────────────────────────────────────────────────────────────┐
│ External Dependencies │
-│ @mariozechner/pi-agent (Agent, tools) │
-│ @mariozechner/pi-ai (models, providers) │
-│ @mariozechner/pi-tui (TUI components) │
+│ @oh-my-pi/pi-agent (Agent, tools) │
+│ @oh-my-pi/pi-ai (models, providers) │
+│ @oh-my-pi/pi-tui (TUI components) │
└─────────────────────────────────────────────────────────────┘
```
@@ -65,7 +65,7 @@ src/
│ ├── system-prompt.ts # buildSystemPrompt(), loadProjectContextFiles()
│ │
│ ├── oauth/ # OAuth authentication (thin wrapper)
-│ │ └── index.ts # Re-exports from @mariozechner/pi-ai with convenience wrappers
+│ │ └── index.ts # Re-exports from @oh-my-pi/pi-ai with convenience wrappers
│ │
│ ├── hooks/ # Hook system for extending behavior
│ │ ├── index.ts # Hook exports
@@ -143,6 +143,7 @@ src/
### AgentSession (core/agent-session.ts)
The central abstraction that wraps the low-level `Agent` with:
+
- Session persistence (via SessionManager)
- Settings persistence (via SettingsManager)
- Model cycling with scoped models
@@ -157,6 +158,7 @@ All three modes (interactive, print, rpc) use AgentSession.
### InteractiveMode (modes/interactive/interactive-mode.ts)
Handles TUI rendering and user interaction:
+
- Subscribes to AgentSession events
- Renders messages, tool executions, streaming
- Manages editor, selectors, key handlers
@@ -175,6 +177,7 @@ The RPC mode exposes the full AgentSession API via JSON commands. See [docs/rpc.
### SessionManager (core/session-manager.ts)
Handles session persistence:
+
- JSONL format for append-only writes
- Session file location management
- Message loading/saving
@@ -183,6 +186,7 @@ Handles session persistence:
### SettingsManager (core/settings-manager.ts)
Handles user preferences:
+
- Default model/provider
- Theme selection
- Queue mode
@@ -193,6 +197,7 @@ Handles user preferences:
### Hook System (core/hooks/)
Extensibility layer for intercepting agent behavior:
+
- **loader.ts**: Discovers and loads hooks from `~/.pi/agent/hooks/`, `.pi/hooks/`, and CLI
- **runner.ts**: Dispatches events to registered hooks
- **tool-wrapper.ts**: Wraps tools to emit `tool_call` and `tool_result` events
@@ -203,6 +208,7 @@ See [docs/hooks.md](docs/hooks.md) for full documentation.
### Custom Tools (core/custom-tools/)
System for adding LLM-callable tools:
+
- **loader.ts**: Discovers and loads tools from `~/.pi/agent/tools/`, `.pi/tools/`, and CLI
- **types.ts**: `CustomToolFactory`, `CustomToolDefinition`, `CustomToolResult`
@@ -211,6 +217,7 @@ See [docs/custom-tools.md](docs/custom-tools.md) for full documentation.
### Skills (core/skills.ts)
On-demand capability packages:
+
- Discovers SKILL.md files from multiple locations
- Provides specialized workflows and instructions
- Loaded when task matches description
diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md
index 352cd4cb3..2d92e28a2 100644
--- a/packages/coding-agent/README.md
+++ b/packages/coding-agent/README.md
@@ -51,20 +51,20 @@ Works on Linux, macOS, and Windows (requires bash; see [Windows Setup](#windows-
**npm (recommended):**
```bash
-npm install -g @mariozechner/pi-coding-agent
+npm install -g @oh-my-pi/pi-coding-agent
```
**Standalone binary:**
Download from [GitHub Releases](https://github.com/badlogic/pi-mono/releases):
-| Platform | Archive |
-|----------|---------|
+| Platform | Archive |
+| ------------------- | ------------------------ |
| macOS Apple Silicon | `pi-darwin-arm64.tar.gz` |
-| macOS Intel | `pi-darwin-x64.tar.gz` |
-| Linux x64 | `pi-linux-x64.tar.gz` |
-| Linux ARM64 | `pi-linux-arm64.tar.gz` |
-| Windows x64 | `pi-windows-x64.zip` |
+| macOS Intel | `pi-darwin-x64.tar.gz` |
+| Linux x64 | `pi-linux-x64.tar.gz` |
+| Linux ARM64 | `pi-linux-arm64.tar.gz` |
+| Windows x64 | `pi-windows-x64.zip` |
```bash
# macOS/Linux
@@ -81,7 +81,7 @@ pi.exe
**Build from source** (requires [Bun](https://bun.sh) 1.0+):
```bash
-git clone https://github.com/badlogic/pi-mono.git
+git clone https://github.com/can1357/oh-my-pi.git
cd pi-mono && npm install
cd packages/coding-agent && npm run build:binary
./dist/pi
@@ -102,7 +102,7 @@ For most users, [Git for Windows](https://git-scm.com/download/win) is sufficien
```json
// ~/.pi/agent/settings.json
{
- "shellPath": "C:\\cygwin64\\bin\\bash.exe"
+ "shellPath": "C:\\cygwin64\\bin\\bash.exe"
}
```
@@ -114,25 +114,25 @@ Add API keys to `~/.pi/agent/auth.json`:
```json
{
- "anthropic": { "type": "api_key", "key": "sk-ant-..." },
- "openai": { "type": "api_key", "key": "sk-..." },
- "google": { "type": "api_key", "key": "..." }
+ "anthropic": { "type": "api_key", "key": "sk-ant-..." },
+ "openai": { "type": "api_key", "key": "sk-..." },
+ "google": { "type": "api_key", "key": "..." }
}
```
**Option 2: Environment variables**
-| Provider | Auth Key | Environment Variable |
-|----------|--------------|---------------------|
-| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
-| OpenAI | `openai` | `OPENAI_API_KEY` |
-| Google | `google` | `GEMINI_API_KEY` |
-| Mistral | `mistral` | `MISTRAL_API_KEY` |
-| Groq | `groq` | `GROQ_API_KEY` |
-| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
-| xAI | `xai` | `XAI_API_KEY` |
+| Provider | Auth Key | Environment Variable |
+| ---------- | ------------ | -------------------- |
+| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` |
+| OpenAI | `openai` | `OPENAI_API_KEY` |
+| Google | `google` | `GEMINI_API_KEY` |
+| Mistral | `mistral` | `MISTRAL_API_KEY` |
+| Groq | `groq` | `GROQ_API_KEY` |
+| Cerebras | `cerebras` | `CEREBRAS_API_KEY` |
+| xAI | `xai` | `XAI_API_KEY` |
| OpenRouter | `openrouter` | `OPENROUTER_API_KEY` |
-| ZAI | `zai` | `ZAI_API_KEY` |
+| ZAI | `zai` | `ZAI_API_KEY` |
Auth file keys take priority over environment variables.
@@ -140,12 +140,12 @@ Auth file keys take priority over environment variables.
Use `/login` to authenticate with subscription-based or free-tier providers:
-| Provider | Models | Cost |
-|----------|--------|------|
-| Anthropic (Claude Pro/Max) | Claude models via your subscription | Subscription |
-| GitHub Copilot | GPT-4o, Claude, Gemini via Copilot subscription | Subscription |
-| Google Gemini CLI | Gemini 2.0/2.5 models | Free (Google account) |
-| Google Antigravity | Gemini 3, Claude, GPT-OSS | Free (Google account) |
+| Provider | Models | Cost |
+| -------------------------- | ----------------------------------------------- | --------------------- |
+| Anthropic (Claude Pro/Max) | Claude models via your subscription | Subscription |
+| GitHub Copilot | GPT-4o, Claude, Gemini via Copilot subscription | Subscription |
+| Google Gemini CLI | Gemini 2.0/2.5 models | Free (Google account) |
+| Google Antigravity | Gemini 3, Claude, GPT-OSS | Free (Google account) |
```bash
pi
@@ -155,10 +155,12 @@ pi
**Note:** `/login` replaces any existing API key for that provider with OAuth credentials in `auth.json`.
**GitHub Copilot notes:**
+
- Press Enter for github.com, or enter your GitHub Enterprise Server domain
- If you get "model not supported" error, enable it in VS Code: Copilot Chat → model selector → select model → "Enable"
**Google providers notes:**
+
- Gemini CLI uses the production Cloud Code Assist endpoint (standard Gemini models)
- Antigravity uses a sandbox endpoint with access to Gemini 3, Claude (sonnet/opus thinking), and GPT-OSS models
- Both are free with any Google account, subject to rate limits
@@ -186,23 +188,23 @@ The agent reads, writes, and edits files, and executes commands via bash.
### Slash Commands
-| Command | Description |
-|---------|-------------|
-| `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
-| `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
-| `/export [file]` | Export session to self-contained HTML |
-| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
-| `/session` | Show session info: path, message counts, token usage, cost |
-| `/hotkeys` | Show all keyboard shortcuts |
-| `/changelog` | Display full version history |
-| `/tree` | Navigate session tree in-place (search, filter, label entries) |
-| `/branch` | Create new conversation branch from a previous message |
-| `/resume` | Switch to a different session (interactive selector) |
-| `/login` | OAuth login for subscription-based models |
-| `/logout` | Clear OAuth tokens |
-| `/new` | Start a new session |
-| `/copy` | Copy last agent message to clipboard |
-| `/compact [instructions]` | Manually compact conversation context |
+| Command | Description |
+| ------------------------- | --------------------------------------------------------------------------- |
+| `/settings` | Open settings menu (thinking, theme, queue mode, toggles) |
+| `/model` | Switch models mid-session (fuzzy search, arrow keys, Enter to select) |
+| `/export [file]` | Export session to self-contained HTML |
+| `/share` | Upload session as secret GitHub gist, get shareable URL (requires `gh` CLI) |
+| `/session` | Show session info: path, message counts, token usage, cost |
+| `/hotkeys` | Show all keyboard shortcuts |
+| `/changelog` | Display full version history |
+| `/tree` | Navigate session tree in-place (search, filter, label entries) |
+| `/branch` | Create new conversation branch from a previous message |
+| `/resume` | Switch to a different session (interactive selector) |
+| `/login` | OAuth login for subscription-based models |
+| `/logout` | Clear OAuth tokens |
+| `/new` | Start a new session |
+| `/copy` | Copy last agent message to clipboard |
+| `/compact [instructions]` | Manually compact conversation context |
### Editor Features
@@ -220,38 +222,38 @@ The agent reads, writes, and edits files, and executes commands via bash.
**Navigation:**
-| Key | Action |
-|-----|--------|
-| Arrow keys | Move cursor / browse history (Up when empty) |
-| Option+Left/Right | Move by word |
-| Ctrl+A / Home / Cmd+Left | Start of line |
-| Ctrl+E / End / Cmd+Right | End of line |
+| Key | Action |
+| ------------------------ | -------------------------------------------- |
+| Arrow keys | Move cursor / browse history (Up when empty) |
+| Option+Left/Right | Move by word |
+| Ctrl+A / Home / Cmd+Left | Start of line |
+| Ctrl+E / End / Cmd+Right | End of line |
**Editing:**
-| Key | Action |
-|-----|--------|
-| Enter | Send message |
-| Shift+Enter / Alt+Enter | New line (Ctrl+Enter on WSL) |
-| Ctrl+W / Option+Backspace | Delete word backwards |
-| Ctrl+U | Delete to start of line |
-| Ctrl+K | Delete to end of line |
+| Key | Action |
+| ------------------------- | ---------------------------- |
+| Enter | Send message |
+| Shift+Enter / Alt+Enter | New line (Ctrl+Enter on WSL) |
+| Ctrl+W / Option+Backspace | Delete word backwards |
+| Ctrl+U | Delete to start of line |
+| Ctrl+K | Delete to end of line |
**Other:**
-| Key | Action |
-|-----|--------|
-| Tab | Path completion / accept autocomplete |
-| Escape | Cancel autocomplete / abort streaming |
-| Ctrl+C | Clear editor (first) / exit (second) |
-| Ctrl+D | Exit (when editor is empty) |
-| Ctrl+Z | Suspend to background (use `fg` in shell to resume) |
-| Shift+Tab | Cycle thinking level |
-| Ctrl+P / Shift+Ctrl+P | Cycle models forward/backward (scoped by `--models`) |
-| Ctrl+L | Open model selector |
-| Ctrl+O | Toggle tool output expansion |
-| Ctrl+T | Toggle thinking block visibility |
-| Ctrl+G | Edit message in external editor (`$VISUAL` or `$EDITOR`) |
+| Key | Action |
+| --------------------- | -------------------------------------------------------- |
+| Tab | Path completion / accept autocomplete |
+| Escape | Cancel autocomplete / abort streaming |
+| Ctrl+C | Clear editor (first) / exit (second) |
+| Ctrl+D | Exit (when editor is empty) |
+| Ctrl+Z | Suspend to background (use `fg` in shell to resume) |
+| Shift+Tab | Cycle thinking level |
+| Ctrl+P / Shift+Ctrl+P | Cycle models forward/backward (scoped by `--models`) |
+| Ctrl+L | Open model selector |
+| Ctrl+O | Toggle tool output expansion |
+| Ctrl+T | Toggle thinking block visibility |
+| Ctrl+G | Edit message in external editor (`$VISUAL` or `$EDITOR`) |
### Bash Mode
@@ -270,6 +272,7 @@ The output becomes part of your next prompt, formatted as:
```
Ran `ls -la`
```
+
```
```
@@ -321,6 +324,7 @@ Long sessions can exhaust context windows. Compaction summarizes older messages
**Manual:** `/compact` or `/compact Focus on the API changes`
**Automatic:** Enable via `/settings`. When enabled, triggers in two cases:
+
- **Overflow recovery**: LLM returns context overflow error. Compacts and auto-retries.
- **Threshold maintenance**: Context exceeds `contextWindow - reserveTokens` after a successful turn. Compacts without retry.
@@ -330,11 +334,11 @@ When disabled, neither case triggers automatic compaction (use `/compact` manual
```json
{
- "compaction": {
- "enabled": true,
- "reserveTokens": 16384,
- "keepRecentTokens": 20000
- }
+ "compaction": {
+ "enabled": true,
+ "reserveTokens": 16384,
+ "keepRecentTokens": 20000
+ }
}
```
@@ -371,6 +375,7 @@ Pi loads `AGENTS.md` (or `CLAUDE.md`) files at startup in this order:
3. **Current directory:** `./AGENTS.md`
Use these for:
+
- Project instructions and guidelines
- Common commands and workflows
- Architecture documentation
@@ -379,10 +384,12 @@ Use these for:
```markdown
# Common Commands
+
- npm run build: Build the project
- npm test: Run tests
# Code Style
+
- Use TypeScript strict mode
- Prefer async/await over promises
```
@@ -400,6 +407,7 @@ This is useful when using pi as different types of agents across repos (coding a
You are a technical writing assistant. Help users write clear documentation.
Focus on:
+
- Concise explanations
- Code examples
- Proper formatting
@@ -413,24 +421,24 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
```json
{
- "providers": {
- "ollama": {
- "baseUrl": "http://localhost:11434/v1",
- "apiKey": "OLLAMA_API_KEY",
- "api": "openai-completions",
- "models": [
- {
- "id": "llama-3.1-8b",
- "name": "Llama 3.1 8B (Local)",
- "reasoning": false,
- "input": ["text"],
- "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
- "contextWindow": 128000,
- "maxTokens": 32000
- }
- ]
- }
- }
+ "providers": {
+ "ollama": {
+ "baseUrl": "http://localhost:11434/v1",
+ "apiKey": "OLLAMA_API_KEY",
+ "api": "openai-completions",
+ "models": [
+ {
+ "id": "llama-3.1-8b",
+ "name": "Llama 3.1 8B (Local)",
+ "reasoning": false,
+ "input": ["text"],
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
+ "contextWindow": 128000,
+ "maxTokens": 32000
+ }
+ ]
+ }
+ }
}
```
@@ -463,16 +471,17 @@ Add custom models (Ollama, vLLM, LM Studio, etc.) via `~/.pi/agent/models.json`:
**OpenAI compatibility (`compat` field):**
-| Field | Description |
-|-------|-------------|
-| `supportsStore` | Whether provider supports `store` field |
-| `supportsDeveloperRole` | Use `developer` vs `system` role |
-| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |
-| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |
+| Field | Description |
+| ------------------------- | ------------------------------------------- |
+| `supportsStore` | Whether provider supports `store` field |
+| `supportsDeveloperRole` | Use `developer` vs `system` role |
+| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |
+| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |
**Live reload:** The file reloads each time you open `/model`. Edit during session; no restart needed.
**Model selection priority:**
+
1. CLI args (`--provider`, `--model`)
2. First from `--models` scope (new sessions only)
3. Restored from session (`--continue`, `--resume`)
@@ -494,57 +503,57 @@ Global `~/.pi/agent/settings.json` stores persistent preferences:
```json
{
- "theme": "dark",
- "defaultProvider": "anthropic",
- "defaultModel": "claude-sonnet-4-20250514",
- "defaultThinkingLevel": "medium",
- "enabledModels": ["anthropic/*", "*gpt*", "gemini-2.5-pro:high"],
- "queueMode": "one-at-a-time",
- "shellPath": "C:\\path\\to\\bash.exe",
- "hideThinkingBlock": false,
- "collapseChangelog": false,
- "compaction": {
- "enabled": true,
- "reserveTokens": 16384,
- "keepRecentTokens": 20000
- },
- "skills": {
- "enabled": true
- },
- "retry": {
- "enabled": true,
- "maxRetries": 3,
- "baseDelayMs": 2000
- },
- "terminal": {
- "showImages": true
- },
- "hooks": ["/path/to/hook.ts"],
- "customTools": ["/path/to/tool.ts"]
+ "theme": "dark",
+ "defaultProvider": "anthropic",
+ "defaultModel": "claude-sonnet-4-20250514",
+ "defaultThinkingLevel": "medium",
+ "enabledModels": ["anthropic/*", "*gpt*", "gemini-2.5-pro:high"],
+ "queueMode": "one-at-a-time",
+ "shellPath": "C:\\path\\to\\bash.exe",
+ "hideThinkingBlock": false,
+ "collapseChangelog": false,
+ "compaction": {
+ "enabled": true,
+ "reserveTokens": 16384,
+ "keepRecentTokens": 20000
+ },
+ "skills": {
+ "enabled": true
+ },
+ "retry": {
+ "enabled": true,
+ "maxRetries": 3,
+ "baseDelayMs": 2000
+ },
+ "terminal": {
+ "showImages": true
+ },
+ "hooks": ["/path/to/hook.ts"],
+ "customTools": ["/path/to/tool.ts"]
}
```
-| Setting | Description | Default |
-|---------|-------------|---------|
-| `theme` | Color theme name | auto-detected |
-| `defaultProvider` | Default model provider | - |
-| `defaultModel` | Default model ID | - |
-| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
-| `enabledModels` | Model patterns for cycling. Supports glob patterns (`github-copilot/*`, `*sonnet*`) and fuzzy matching. Same as `--models` CLI flag | - |
-| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
-| `shellPath` | Custom bash path (Windows) | auto-detected |
-| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
-| `collapseChangelog` | Show condensed changelog after update | `false` |
-| `compaction.enabled` | Enable auto-compaction | `true` |
-| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
-| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
-| `skills.enabled` | Enable skills discovery | `true` |
-| `retry.enabled` | Auto-retry on transient errors | `true` |
-| `retry.maxRetries` | Maximum retry attempts | `3` |
-| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
-| `terminal.showImages` | Render images inline (supported terminals) | `true` |
-| `hooks` | Additional hook file paths | `[]` |
-| `customTools` | Additional custom tool file paths | `[]` |
+| Setting | Description | Default |
+| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------- |
+| `theme` | Color theme name | auto-detected |
+| `defaultProvider` | Default model provider | - |
+| `defaultModel` | Default model ID | - |
+| `defaultThinkingLevel` | Thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh` | - |
+| `enabledModels` | Model patterns for cycling. Supports glob patterns (`github-copilot/*`, `*sonnet*`) and fuzzy matching. Same as `--models` CLI flag | - |
+| `queueMode` | Message queue mode: `all` or `one-at-a-time` | `one-at-a-time` |
+| `shellPath` | Custom bash path (Windows) | auto-detected |
+| `hideThinkingBlock` | Hide thinking blocks in output (Ctrl+T to toggle) | `false` |
+| `collapseChangelog` | Show condensed changelog after update | `false` |
+| `compaction.enabled` | Enable auto-compaction | `true` |
+| `compaction.reserveTokens` | Tokens to reserve before compaction triggers | `16384` |
+| `compaction.keepRecentTokens` | Recent tokens to keep after compaction | `20000` |
+| `skills.enabled` | Enable skills discovery | `true` |
+| `retry.enabled` | Auto-retry on transient errors | `true` |
+| `retry.maxRetries` | Maximum retry attempts | `3` |
+| `retry.baseDelayMs` | Base delay for exponential backoff | `2000` |
+| `terminal.showImages` | Render images inline (supported terminals) | `true` |
+| `hooks` | Additional hook file paths | `[]` |
+| `customTools` | Additional custom tool file paths | `[]` |
---
@@ -560,7 +569,7 @@ Select theme via `/settings` or set in `~/.pi/agent/settings.json`.
```bash
mkdir -p ~/.pi/agent/themes
-cp $(npm root -g)/@mariozechner/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
+cp $(npm root -g)/@oh-my-pi/pi-coding-agent/dist/theme/dark.json ~/.pi/agent/themes/my-theme.json
```
Select with `/settings`, then edit the file. Changes apply on save.
@@ -574,6 +583,7 @@ Select with `/settings`, then edit the file. Changes apply on save.
Define reusable prompts as Markdown files:
**Locations:**
+
- Global: `~/.pi/agent/commands/*.md`
- Project: `.pi/commands/*.md`
@@ -583,7 +593,9 @@ Define reusable prompts as Markdown files:
---
description: Review staged git changes
---
+
Review the staged changes (`git diff --cached`). Focus on:
+
- Bugs and logic errors
- Security issues
- Error handling gaps
@@ -597,16 +609,17 @@ Filename (without `.md`) becomes the command name. Description shown in autocomp
---
description: Create a component
---
+
Create a React component named $1 with features: $@
```
Usage: `/component Button "onClick handler" "disabled support"`
+
- `$1` = `Button`
- `$@` = all arguments joined
**Namespacing:** Subdirectories create prefixes. `.pi/commands/frontend/component.md` → `/component (project:frontend)`
-
### Skills
Skills are self-contained capability packages that the agent loads on-demand. Pi implements the [Agent Skills standard](https://agentskills.io/specification), warning about violations but remaining lenient.
@@ -614,6 +627,7 @@ Skills are self-contained capability packages that the agent loads on-demand. Pi
A skill provides specialized workflows, setup instructions, helper scripts, and reference documentation for specific tasks. Skills are loaded when the agent decides a task matches the description, or when you explicitly ask to use one.
**Example use cases:**
+
- Web search and content extraction (Brave Search API)
- Browser automation via Chrome DevTools Protocol
- Google Calendar, Gmail, Drive integration
@@ -622,6 +636,7 @@ A skill provides specialized workflows, setup instructions, helper scripts, and
- YouTube transcript extraction
**Skill locations:**
+
- Pi user: `~/.pi/agent/skills/**/SKILL.md` (recursive)
- Pi project: `.pi/skills/**/SKILL.md` (recursive)
- Claude Code: `~/.claude/skills/*/SKILL.md` and `.claude/skills/*/SKILL.md`
@@ -638,13 +653,15 @@ description: Web search via Brave Search API. Use for documentation, facts, or w
# Brave Search
## Setup
+
\`\`\`bash
cd /path/to/brave-search && npm install
\`\`\`
## Usage
+
\`\`\`bash
-./search.js "query" # Basic search
+./search.js "query" # Basic search
./search.js "query" --content # Include page content
\`\`\`
```
@@ -667,6 +684,7 @@ Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecyc
- **Inject messages from external sources to wake up the agent** (file watchers, webhooks, CI systems)
**Hook locations:**
+
- Global: `~/.pi/agent/hooks/*.ts`
- Project: `.pi/hooks/*.ts`
- CLI: `--hook ` (for debugging)
@@ -674,16 +692,16 @@ Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecyc
**Quick example** (permission gate):
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
- pi.on("tool_call", async (event, ctx) => {
- if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) {
- const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string);
- if (!ok) return { block: true, reason: "Blocked by user" };
- }
- return undefined;
- });
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName === "bash" && /sudo/.test(event.input.command as string)) {
+ const ok = await ctx.ui.confirm("Allow sudo?", event.input.command as string);
+ if (!ok) return { block: true, reason: "Blocked by user" };
+ }
+ return undefined;
+ });
}
```
@@ -693,21 +711,24 @@ Use `pi.sendMessage(message, triggerTurn?)` to inject messages into the session.
```typescript
import * as fs from "node:fs";
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
- pi.on("session_start", async () => {
- fs.watch("/tmp/trigger.txt", () => {
- const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
- if (content) {
- pi.sendMessage({
- customType: "file-trigger",
- content,
- display: true,
- }, true); // triggerTurn: start agent loop
- }
- });
- });
+ pi.on("session_start", async () => {
+ fs.watch("/tmp/trigger.txt", () => {
+ const content = fs.readFileSync("/tmp/trigger.txt", "utf-8").trim();
+ if (content) {
+ pi.sendMessage(
+ {
+ customType: "file-trigger",
+ content,
+ display: true,
+ },
+ true
+ ); // triggerTurn: start agent loop
+ }
+ });
+ });
}
```
@@ -720,10 +741,12 @@ export default function (pi: HookAPI) {
Custom tools let you extend the built-in toolset (read, write, edit, bash, ...) and are called by the LLM directly. They are TypeScript modules that define tools with optional custom TUI integration for getting user input and custom tool call and result rendering.
**Tool locations (auto-discovered):**
+
- Global: `~/.pi/agent/tools/*/index.ts`
- Project: `.pi/tools/*/index.ts`
**Explicit paths:**
+
- CLI: `--tool ` (any .ts file)
- Settings: `customTools` array in `settings.json`
@@ -731,29 +754,30 @@ Custom tools let you extend the built-in toolset (read, write, edit, bash, ...)
```typescript
import { Type } from "@sinclair/typebox";
-import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
- name: "greet",
- label: "Greeting",
- description: "Generate a greeting",
- parameters: Type.Object({
- name: Type.String({ description: "Name to greet" }),
- }),
+ 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) {
- const { name } = params as { name: string };
- return {
- content: [{ type: "text", text: `Hello, ${name}!` }],
- details: { greeted: name },
- };
- },
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
+ const { name } = params as { name: string };
+ return {
+ content: [{ type: "text", text: `Hello, ${name}!` }],
+ details: { greeted: name },
+ };
+ },
});
export default factory;
```
**Features:**
+
- Access to `pi.cwd`, `pi.exec()`, `pi.ui` (select/confirm/input dialogs)
- Session lifecycle via `onSession` callback (for state reconstruction)
- Custom rendering via `renderCall()` and `renderResult()` methods
@@ -775,29 +799,29 @@ pi [options] [@files...] [messages...]
### Options
-| Option | Description |
-|--------|-------------|
-| `--provider ` | Provider: `anthropic`, `openai`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
-| `--model ` | Model ID |
-| `--api-key ` | API key (overrides environment) |
-| `--system-prompt ` | Custom system prompt (text or file path) |
-| `--append-system-prompt ` | Append to system prompt |
-| `--mode ` | Output mode: `text`, `json`, `rpc` (implies `--print`) |
-| `--print`, `-p` | Non-interactive: process prompt and exit |
-| `--no-session` | Don't save session |
-| `--session ` | Use specific session file |
-| `--session-dir ` | Directory for session storage and lookup |
-| `--continue`, `-c` | Continue most recent session |
-| `--resume`, `-r` | Select session to resume |
-| `--models ` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) |
-| `--tools ` | Comma-separated tool list (default: `read,bash,edit,write`) |
-| `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
-| `--hook ` | Load a hook file (can be used multiple times) |
-| `--no-skills` | Disable skills discovery and loading |
-| `--skills ` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) |
-| `--export [output]` | Export session to HTML |
-| `--help`, `-h` | Show help |
-| `--version`, `-v` | Show version |
+| Option | Description |
+| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--provider ` | Provider: `anthropic`, `openai`, `google`, `mistral`, `xai`, `groq`, `cerebras`, `openrouter`, `zai`, `github-copilot`, `google-gemini-cli`, `google-antigravity`, or custom |
+| `--model ` | Model ID |
+| `--api-key ` | API key (overrides environment) |
+| `--system-prompt ` | Custom system prompt (text or file path) |
+| `--append-system-prompt ` | Append to system prompt |
+| `--mode ` | Output mode: `text`, `json`, `rpc` (implies `--print`) |
+| `--print`, `-p` | Non-interactive: process prompt and exit |
+| `--no-session` | Don't save session |
+| `--session ` | Use specific session file |
+| `--session-dir ` | Directory for session storage and lookup |
+| `--continue`, `-c` | Continue most recent session |
+| `--resume`, `-r` | Select session to resume |
+| `--models ` | Comma-separated patterns for Ctrl+P cycling. Supports glob patterns (e.g., `anthropic/*`, `*sonnet*:high`) and fuzzy matching (e.g., `sonnet,haiku:low`) |
+| `--tools ` | Comma-separated tool list (default: `read,bash,edit,write`) |
+| `--thinking ` | Thinking level: `off`, `minimal`, `low`, `medium`, `high` |
+| `--hook ` | Load a hook file (can be used multiple times) |
+| `--no-skills` | Disable skills discovery and loading |
+| `--skills ` | Comma-separated glob patterns to filter skills (e.g., `git-*,docker`) |
+| `--export [output]` | Export session to HTML |
+| `--help`, `-h` | Show help |
+| `--version`, `-v` | Show version |
### File Arguments
@@ -857,22 +881,22 @@ pi --export session.jsonl output.html
### Default Tools
-| Tool | Description |
-|------|-------------|
-| `read` | Read file contents. Images sent as attachments. Text: first 2000 lines, lines truncated at 2000 chars. Use offset/limit for large files. |
-| `write` | Write/overwrite file. Creates parent directories. |
-| `edit` | Replace exact text in file. Must match exactly including whitespace. Fails if text appears multiple times or not found. |
-| `bash` | Execute command. Returns stdout/stderr. Optional `timeout` parameter. |
+| Tool | Description |
+| ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| `read` | Read file contents. Images sent as attachments. Text: first 2000 lines, lines truncated at 2000 chars. Use offset/limit for large files. |
+| `write` | Write/overwrite file. Creates parent directories. |
+| `edit` | Replace exact text in file. Must match exactly including whitespace. Fails if text appears multiple times or not found. |
+| `bash` | Execute command. Returns stdout/stderr. Optional `timeout` parameter. |
### Read-Only Tools
Available via `--tools` flag:
-| Tool | Description |
-|------|-------------|
+| Tool | Description |
+| ------ | --------------------------------------------------------------- |
| `grep` | Search file contents (regex or literal). Respects `.gitignore`. |
-| `find` | Search for files by glob pattern. Respects `.gitignore`. |
-| `ls` | List directory contents. Includes dotfiles. |
+| `find` | Search for files by glob pattern. Respects `.gitignore`. |
+| `ls` | List directory contents. Includes dotfiles. |
Example: `--tools read,grep,find,ls` for code review without modification.
@@ -887,27 +911,28 @@ For adding new tools, see [Custom Tools](#custom-tools) in the Configuration sec
For embedding pi in Node.js/TypeScript applications, use the SDK:
```typescript
-import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@oh-my-pi/pi-coding-agent";
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
- authStorage,
- modelRegistry,
+ sessionManager: SessionManager.inMemory(),
+ authStorage,
+ modelRegistry,
});
session.subscribe((event) => {
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
});
await session.prompt("What files are in the current directory?");
```
The SDK provides full control over:
+
- Model selection and thinking level
- System prompt (replace or modify)
- Tools (built-in subsets, custom tools)
@@ -930,6 +955,7 @@ pi --mode rpc --no-session
```
Send JSON commands on stdin:
+
```json
{"type":"prompt","message":"List all .ts files"}
{"type":"abort"}
@@ -976,10 +1002,10 @@ Configure via `package.json`:
```json
{
- "piConfig": {
- "name": "pi",
- "configDir": ".pi"
- }
+ "piConfig": {
+ "name": "pi",
+ "configDir": ".pi"
+ }
}
```
@@ -1011,5 +1037,5 @@ MIT
## See Also
-- [@mariozechner/pi-ai](https://www.npmjs.com/package/@mariozechner/pi-ai): Core LLM toolkit
-- [@mariozechner/pi-agent](https://www.npmjs.com/package/@mariozechner/pi-agent): Agent framework
+- [@oh-my-pi/pi-ai](https://www.npmjs.com/package/@oh-my-pi/pi-ai): Core LLM toolkit
+- [@oh-my-pi/pi-agent](https://www.npmjs.com/package/@oh-my-pi/pi-agent): Agent framework
diff --git a/packages/coding-agent/docs/compaction.md b/packages/coding-agent/docs/compaction.md
index 66055c47a..408a03a54 100644
--- a/packages/coding-agent/docs/compaction.md
+++ b/packages/coding-agent/docs/compaction.md
@@ -3,6 +3,7 @@
LLMs have limited context windows. When conversations grow too long, pi uses compaction to summarize older content while preserving recent work. This page covers both auto-compaction and branch summarization.
**Source files:**
+
- [`src/core/compaction/compaction.ts`](../src/core/compaction/compaction.ts) - Auto-compaction logic
- [`src/core/compaction/branch-summarization.ts`](../src/core/compaction/branch-summarization.ts) - Branch summarization
- [`src/core/compaction/utils.ts`](../src/core/compaction/utils.ts) - Shared utilities (file tracking, serialization)
@@ -13,10 +14,10 @@ LLMs have limited context windows. When conversations grow too long, pi uses com
Pi has two summarization mechanisms:
-| Mechanism | Trigger | Purpose |
-|-----------|---------|---------|
-| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
-| Branch summarization | `/tree` navigation | Preserve context when switching branches |
+| Mechanism | Trigger | Purpose |
+| -------------------- | ---------------------------------------- | ----------------------------------------- |
+| Compaction | Context exceeds threshold, or `/compact` | Summarize old messages to free up context |
+| Branch summarization | `/tree` navigation | Preserve context when switching branches |
Both use the same structured summary format and track file operations cumulatively.
@@ -99,12 +100,14 @@ Split turn (one huge turn exceeds budget):
```
For split turns, pi generates two summaries and merges them:
+
1. **History summary**: Previous context (if any)
2. **Turn prefix summary**: The early part of the split turn
### Cut Point Rules
Valid cut points are:
+
- User messages
- Assistant messages
- BashExecution messages
@@ -118,21 +121,21 @@ Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface CompactionEntry {
- type: "compaction";
- id: string;
- parentId: string;
- timestamp: number;
- summary: string;
- firstKeptEntryId: string;
- tokensBefore: number;
- fromHook?: boolean; // true if hook provided the compaction
- details?: T; // hook-specific data
+ type: "compaction";
+ id: string;
+ parentId: string;
+ timestamp: number;
+ summary: string;
+ firstKeptEntryId: string;
+ tokensBefore: number;
+ fromHook?: boolean; // true if hook provided the compaction
+ details?: T; // hook-specific data
}
// Default compaction uses this for details (from compaction.ts):
interface CompactionDetails {
- readFiles: string[];
- modifiedFiles: string[];
+ readFiles: string[];
+ modifiedFiles: string[];
}
```
@@ -174,6 +177,7 @@ After navigation with summary:
### Cumulative File Tracking
Both compaction and branch summarization track files cumulatively. When generating a summary, pi extracts file operations from:
+
- Tool calls in the messages being summarized
- Previous compaction or branch summary `details` (if any)
@@ -185,20 +189,20 @@ Defined in [`src/core/session-manager.ts`](../src/core/session-manager.ts):
```typescript
interface BranchSummaryEntry {
- type: "branch_summary";
- id: string;
- parentId: string;
- timestamp: number;
- summary: string;
- fromId: string; // Entry we navigated from
- fromHook?: boolean; // true if hook provided the summary
- details?: T; // hook-specific data
+ type: "branch_summary";
+ id: string;
+ parentId: string;
+ timestamp: number;
+ summary: string;
+ fromId: string; // Entry we navigated from
+ fromHook?: boolean; // true if hook provided the summary
+ details?: T; // hook-specific data
}
// Default branch summarization uses this for details (from branch-summarization.ts):
interface BranchSummaryDetails {
- readFiles: string[];
- modifiedFiles: string[];
+ readFiles: string[];
+ modifiedFiles: string[];
}
```
@@ -212,28 +216,37 @@ Both compaction and branch summarization use the same structured format:
```markdown
## Goal
+
[What the user is trying to accomplish]
## Constraints & Preferences
+
- [Requirements mentioned by user]
## Progress
+
### Done
+
- [x] [Completed tasks]
### In Progress
+
- [ ] [Current work]
### Blocked
+
- [Issues, if any]
## Key Decisions
+
- **[Decision]**: [Rationale]
## Next Steps
+
1. [What should happen next]
## Critical Context
+
- [Data needed to continue]
@@ -270,31 +283,33 @@ Fired before auto-compaction or `/compact`. Can cancel or provide custom summary
```typescript
pi.on("session_before_compact", async (event, ctx) => {
- const { preparation, branchEntries, customInstructions, signal } = event;
+ const { preparation, branchEntries, customInstructions, signal } = event;
- // preparation.messagesToSummarize - messages to summarize
- // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
- // preparation.previousSummary - previous compaction summary
- // preparation.fileOps - extracted file operations
- // preparation.tokensBefore - context tokens before compaction
- // preparation.firstKeptEntryId - where kept messages start
- // preparation.settings - compaction settings
+ // preparation.messagesToSummarize - messages to summarize
+ // preparation.turnPrefixMessages - split turn prefix (if isSplitTurn)
+ // preparation.previousSummary - previous compaction summary
+ // preparation.fileOps - extracted file operations
+ // preparation.tokensBefore - context tokens before compaction
+ // preparation.firstKeptEntryId - where kept messages start
+ // preparation.settings - compaction settings
- // branchEntries - all entries on current branch (for custom state)
- // signal - AbortSignal (pass to LLM calls)
+ // branchEntries - all entries on current branch (for custom state)
+ // signal - AbortSignal (pass to LLM calls)
- // Cancel:
- return { cancel: true };
+ // Cancel:
+ return { cancel: true };
- // Custom summary:
- return {
- compaction: {
- summary: "Your summary...",
- firstKeptEntryId: preparation.firstKeptEntryId,
- tokensBefore: preparation.tokensBefore,
- details: { /* custom data */ },
- }
- };
+ // Custom summary:
+ return {
+ compaction: {
+ summary: "Your summary...",
+ firstKeptEntryId: preparation.firstKeptEntryId,
+ tokensBefore: preparation.tokensBefore,
+ details: {
+ /* custom data */
+ },
+ },
+ };
});
```
@@ -303,32 +318,30 @@ pi.on("session_before_compact", async (event, ctx) => {
To generate a summary with your own model, convert messages to text using `serializeConversation`:
```typescript
-import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { convertToLlm, serializeConversation } from "@oh-my-pi/pi-coding-agent";
pi.on("session_before_compact", async (event, ctx) => {
- const { preparation } = event;
-
- // Convert AgentMessage[] to Message[], then serialize to text
- const conversationText = serializeConversation(
- convertToLlm(preparation.messagesToSummarize)
- );
- // Returns:
- // [User]: message text
- // [Assistant thinking]: thinking content
- // [Assistant]: response text
- // [Assistant tool calls]: read(path="..."); bash(command="...")
- // [Tool result]: output text
+ const { preparation } = event;
- // Now send to your model for summarization
- const summary = await myModel.summarize(conversationText);
-
- return {
- compaction: {
- summary,
- firstKeptEntryId: preparation.firstKeptEntryId,
- tokensBefore: preparation.tokensBefore,
- }
- };
+ // Convert AgentMessage[] to Message[], then serialize to text
+ const conversationText = serializeConversation(convertToLlm(preparation.messagesToSummarize));
+ // Returns:
+ // [User]: message text
+ // [Assistant thinking]: thinking content
+ // [Assistant]: response text
+ // [Assistant tool calls]: read(path="..."); bash(command="...")
+ // [Tool result]: output text
+
+ // Now send to your model for summarization
+ const summary = await myModel.summarize(conversationText);
+
+ return {
+ compaction: {
+ summary,
+ firstKeptEntryId: preparation.firstKeptEntryId,
+ tokensBefore: preparation.tokensBefore,
+ },
+ };
});
```
@@ -340,26 +353,28 @@ Fired before `/tree` navigation. Always fires regardless of whether user chose t
```typescript
pi.on("session_before_tree", async (event, ctx) => {
- const { preparation, signal } = event;
+ const { preparation, signal } = event;
- // preparation.targetId - where we're navigating to
- // preparation.oldLeafId - current position (being abandoned)
- // preparation.commonAncestorId - shared ancestor
- // preparation.entriesToSummarize - entries that would be summarized
- // preparation.userWantsSummary - whether user chose to summarize
+ // preparation.targetId - where we're navigating to
+ // preparation.oldLeafId - current position (being abandoned)
+ // preparation.commonAncestorId - shared ancestor
+ // preparation.entriesToSummarize - entries that would be summarized
+ // preparation.userWantsSummary - whether user chose to summarize
- // Cancel navigation entirely:
- return { cancel: true };
+ // Cancel navigation entirely:
+ return { cancel: true };
- // Provide custom summary (only used if userWantsSummary is true):
- if (preparation.userWantsSummary) {
- return {
- summary: {
- summary: "Your summary...",
- details: { /* custom data */ },
- }
- };
- }
+ // Provide custom summary (only used if userWantsSummary is true):
+ if (preparation.userWantsSummary) {
+ return {
+ summary: {
+ summary: "Your summary...",
+ details: {
+ /* custom data */
+ },
+ },
+ };
+ }
});
```
@@ -371,18 +386,18 @@ Configure compaction in `~/.pi/agent/settings.json` or `/.pi/settin
```json
{
- "compaction": {
- "enabled": true,
- "reserveTokens": 16384,
- "keepRecentTokens": 20000
- }
+ "compaction": {
+ "enabled": true,
+ "reserveTokens": 16384,
+ "keepRecentTokens": 20000
+ }
}
```
-| Setting | Default | Description |
-|---------|---------|-------------|
-| `enabled` | `true` | Enable auto-compaction |
-| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
+| Setting | Default | Description |
+| ------------------ | ------- | -------------------------------------- |
+| `enabled` | `true` | Enable auto-compaction |
+| `reserveTokens` | `16384` | Tokens to reserve for LLM response |
| `keepRecentTokens` | `20000` | Recent tokens to keep (not summarized) |
Disable auto-compaction with `"enabled": false`. You can still compact manually with `/compact`.
diff --git a/packages/coding-agent/docs/custom-tools.md b/packages/coding-agent/docs/custom-tools.md
index e847880ab..8616e8e3e 100644
--- a/packages/coding-agent/docs/custom-tools.md
+++ b/packages/coding-agent/docs/custom-tools.md
@@ -5,6 +5,7 @@
Custom tools are additional tools that the LLM can call directly, just like the built-in `read`, `write`, `edit`, and `bash` tools. They are TypeScript modules that define callable functions with parameters, return values, and optional TUI rendering.
**Key capabilities:**
+
- **User interaction** - Prompt users via `pi.ui` (select, confirm, input dialogs)
- **Custom rendering** - Control how tool calls and results appear via `renderCall`/`renderResult`
- **TUI components** - Render custom components with `pi.ui.custom()` (see [tui.md](tui.md))
@@ -12,6 +13,7 @@ Custom tools are additional tools that the LLM can call directly, just like the
- **Streaming results** - Send partial updates via `onUpdate` callback
**Example use cases:**
+
- Interactive dialogs (questions with selectable options)
- Stateful tools (todo lists, connection pools)
- Rich output rendering (progress indicators, structured views)
@@ -19,12 +21,12 @@ Custom tools are additional tools that the LLM can call directly, just like the
**When to use custom tools vs. alternatives:**
-| Need | Solution |
-|------|----------|
-| Always-needed context (conventions, commands) | AGENTS.md |
-| User triggers a specific prompt template | Slash command |
-| On-demand capability package (workflows, scripts, setup) | Skill |
-| Additional tool directly callable by the LLM | **Custom tool** |
+| Need | Solution |
+| -------------------------------------------------------- | --------------- |
+| Always-needed context (conventions, commands) | AGENTS.md |
+| User triggers a specific prompt template | Slash command |
+| On-demand capability package (workflows, scripts, setup) | Skill |
+| Additional tool directly callable by the LLM | **Custom tool** |
See [examples/custom-tools/](../examples/custom-tools/) for working examples.
@@ -33,23 +35,23 @@ See [examples/custom-tools/](../examples/custom-tools/) for working examples.
Create a file `~/.pi/agent/tools/hello/index.ts`:
```typescript
-import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
- name: "hello",
- label: "Hello",
- description: "A simple greeting tool",
- parameters: pi.typebox.Type.Object({
- name: pi.typebox.Type.String({ description: "Name to greet" }),
- }),
+ name: "hello",
+ label: "Hello",
+ description: "A simple greeting tool",
+ parameters: pi.typebox.Type.Object({
+ name: pi.typebox.Type.String({ description: "Name to greet" }),
+ }),
- async execute(toolCallId, params, onUpdate, ctx, signal) {
- const { name } = params as { name: string };
- return {
- content: [{ type: "text", text: `Hello, ${name}!` }],
- details: { greeted: name },
- };
- },
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
+ const { name } = params as { name: string };
+ return {
+ content: [{ type: "text", text: `Hello, ${name}!` }],
+ details: { greeted: name },
+ };
+ },
});
export default factory;
@@ -61,14 +63,15 @@ The tool is automatically discovered and available in your next pi session.
Tools must be in a subdirectory with an `index.ts` entry point:
-| Location | Scope | Auto-discovered |
-|----------|-------|-----------------|
-| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes |
-| `.pi/tools/*/index.ts` | Project-local | Yes |
-| `settings.json` `customTools` array | Configured paths | Yes |
-| `--tool ` CLI flag | One-off/debugging | No |
+| Location | Scope | Auto-discovered |
+| ----------------------------------- | --------------------- | --------------- |
+| `~/.pi/agent/tools/*/index.ts` | Global (all projects) | Yes |
+| `.pi/tools/*/index.ts` | Project-local | Yes |
+| `settings.json` `customTools` array | Configured paths | Yes |
+| `--tool ` CLI flag | One-off/debugging | No |
**Example structure:**
+
```
~/.pi/agent/tools/
├── hello/
@@ -87,12 +90,12 @@ Tools must be in a subdirectory with an `index.ts` entry point:
Custom tools can import from these packages:
-| Package | Purpose | Import Method |
-|---------|---------|---------------|
-| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | Via `pi.typebox.*` (injected) |
-| `@mariozechner/pi-coding-agent` | Types and utilities | Via `pi.pi.*` (injected) or direct import for types |
-| `@mariozechner/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | Via `pi.pi.*` (re-exported through coding-agent) |
-| `@mariozechner/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | Via `pi.pi.*` (re-exported through coding-agent) |
+| Package | Purpose | Import Method |
+| --------------------------- | --------------------------------------------------------- | --------------------------------------------------- |
+| `@sinclair/typebox` | Schema definitions (`Type.Object`, `Type.String`, etc.) | Via `pi.typebox.*` (injected) |
+| `@oh-my-pi/pi-coding-agent` | Types and utilities | Via `pi.pi.*` (injected) or direct import for types |
+| `@oh-my-pi/pi-ai` | AI utilities (`StringEnum` for Google-compatible enums) | Via `pi.pi.*` (re-exported through coding-agent) |
+| `@oh-my-pi/pi-tui` | TUI components (`Text`, `Box`, etc. for custom rendering) | Via `pi.pi.*` (re-exported through coding-agent) |
Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
@@ -102,51 +105,57 @@ Node.js built-in modules (`node:fs`, `node:path`, etc.) are also available.
```typescript
import type {
- CustomTool,
- CustomToolContext,
- CustomToolFactory,
- CustomToolSessionEvent,
-} from "@mariozechner/pi-coding-agent";
+ CustomTool,
+ CustomToolContext,
+ CustomToolFactory,
+ CustomToolSessionEvent,
+} from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => {
- // Destructure injected dependencies
- const { Type } = pi.typebox;
- const { StringEnum } = pi.pi;
- const { Text } = pi.pi;
+ // Destructure injected dependencies
+ const { Type } = pi.typebox;
+ const { StringEnum } = pi.pi;
+ const { Text } = pi.pi;
- return {
- name: "my_tool",
- label: "My Tool",
- description: "What this tool does (be specific for LLM)",
- parameters: Type.Object({
- // Use StringEnum for string enums (Google API compatible)
- action: StringEnum(["list", "add", "remove"] as const),
- text: Type.Optional(Type.String()),
- }),
+ return {
+ name: "my_tool",
+ label: "My Tool",
+ description: "What this tool does (be specific for LLM)",
+ parameters: Type.Object({
+ // Use StringEnum for string enums (Google API compatible)
+ action: StringEnum(["list", "add", "remove"] as const),
+ text: Type.Optional(Type.String()),
+ }),
- async execute(toolCallId, params, onUpdate, ctx, signal) {
- // signal - AbortSignal for cancellation
- // onUpdate - Callback for streaming partial results
- // ctx - CustomToolContext with sessionManager, modelRegistry, model
- return {
- content: [{ type: "text", text: "Result for LLM" }],
- details: { /* structured data for rendering */ },
- };
- },
+ async execute(toolCallId, params, onUpdate, ctx, signal) {
+ // signal - AbortSignal for cancellation
+ // onUpdate - Callback for streaming partial results
+ // ctx - CustomToolContext with sessionManager, modelRegistry, model
+ return {
+ content: [{ type: "text", text: "Result for LLM" }],
+ details: {
+ /* structured data for rendering */
+ },
+ };
+ },
- // Optional: Session lifecycle callback
- onSession(event, ctx) {
- if (event.reason === "shutdown") {
- // Cleanup resources (close connections, save state, etc.)
- return;
- }
- // Reconstruct state from ctx.sessionManager.getBranch()
- },
+ // Optional: Session lifecycle callback
+ onSession(event, ctx) {
+ if (event.reason === "shutdown") {
+ // Cleanup resources (close connections, save state, etc.)
+ return;
+ }
+ // Reconstruct state from ctx.sessionManager.getBranch()
+ },
- // Optional: Custom rendering
- renderCall(args, theme) { /* return Component */ },
- renderResult(result, options, theme) { /* return Component */ },
- };
+ // Optional: Custom rendering
+ renderCall(args, theme) {
+ /* return Component */
+ },
+ renderResult(result, options, theme) {
+ /* return Component */
+ },
+ };
};
export default factory;
@@ -160,32 +169,32 @@ The factory receives a `CustomToolAPI` object (named `pi` by convention):
```typescript
interface CustomToolAPI {
- cwd: string; // Current working directory
- exec(command: string, args: string[], options?: ExecOptions): Promise;
- ui: ToolUIContext;
- hasUI: boolean; // false in --print or --mode rpc
- typebox: typeof import("@sinclair/typebox"); // Injected @sinclair/typebox
- pi: typeof import("@mariozechner/pi-coding-agent"); // Injected pi-coding-agent exports
+ cwd: string; // Current working directory
+ exec(command: string, args: string[], options?: ExecOptions): Promise;
+ ui: ToolUIContext;
+ hasUI: boolean; // false in --print or --mode rpc
+ typebox: typeof import("@sinclair/typebox"); // Injected @sinclair/typebox
+ pi: typeof import("@oh-my-pi/pi-coding-agent"); // Injected pi-coding-agent exports
}
interface ToolUIContext {
- select(title: string, options: string[]): Promise;
- confirm(title: string, message: string): Promise;
- input(title: string, placeholder?: string): Promise;
- notify(message: string, type?: "info" | "warning" | "error"): void;
- custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
+ select(title: string, options: string[]): Promise;
+ confirm(title: string, message: string): Promise;
+ input(title: string, placeholder?: string): Promise;
+ notify(message: string, type?: "info" | "warning" | "error"): void;
+ custom(component: Component & { dispose?(): void }): { close: () => void; requestRender: () => void };
}
interface ExecOptions {
- signal?: AbortSignal; // Cancel the process
- timeout?: number; // Timeout in milliseconds
+ signal?: AbortSignal; // Cancel the process
+ timeout?: number; // Timeout in milliseconds
}
interface ExecResult {
- stdout: string;
- stderr: string;
- code: number;
- killed?: boolean; // True if process was killed by signal/timeout
+ stdout: string;
+ stderr: string;
+ code: number;
+ killed?: boolean; // True if process was killed by signal/timeout
}
```
@@ -212,18 +221,19 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
```typescript
async execute(toolCallId, params, onUpdate, ctx, signal) {
const { path } = params as { path: string };
-
+
// Throw on error - pi will catch it and report to the LLM
if (!fs.existsSync(path)) {
throw new Error(`File not found: ${path}`);
}
-
+
// Return content only on success
return { content: [{ type: "text", text: "Success" }] };
}
```
Thrown errors are:
+
- Reported to the LLM as tool errors (with `isError: true`)
- Emitted to hooks via `tool_result` event (hooks can inspect `event.isError`)
- Displayed in the TUI with error styling
@@ -234,12 +244,12 @@ The `execute` and `onSession` callbacks receive a `CustomToolContext`:
```typescript
interface CustomToolContext {
- sessionManager: ReadonlySessionManager; // Read-only access to session
- modelRegistry: ModelRegistry; // For API key resolution
- model: Model | undefined; // Current model (may be undefined)
- isIdle(): boolean; // Whether agent is streaming
- hasQueuedMessages(): boolean; // Whether user has queued messages
- abort(): void; // Abort current operation (fire-and-forget)
+ sessionManager: ReadonlySessionManager; // Read-only access to session
+ modelRegistry: ModelRegistry; // For API key resolution
+ model: Model | undefined; // Current model (may be undefined)
+ isIdle(): boolean; // Whether agent is streaming
+ hasQueuedMessages(): boolean; // Whether user has queued messages
+ abort(): void; // Abort current operation (fire-and-forget)
}
```
@@ -273,7 +283,7 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
const text = await pi.ui.editor("Edit your response:", "prefilled text");
// Returns edited text or undefined if cancelled (Escape)
// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
-
+
if (!text) {
return { content: [{ type: "text", text: "Cancelled" }] };
}
@@ -287,12 +297,13 @@ Tools can implement `onSession` to react to session changes:
```typescript
interface CustomToolSessionEvent {
- reason: "start" | "switch" | "branch" | "tree" | "shutdown";
- previousSessionFile: string | undefined;
+ reason: "start" | "switch" | "branch" | "tree" | "shutdown";
+ previousSessionFile: string | undefined;
}
```
**Reasons:**
+
- `start`: Initial session load on startup
- `switch`: User started a new session (`/new`) or switched to a different session (`/resume`)
- `branch`: User branched from a previous message (`/branch`)
@@ -357,6 +368,7 @@ const factory: CustomToolFactory = (pi) => {
```
This pattern ensures:
+
- When user branches, state is correct for that point in history
- When user switches sessions, state matches that session
- When user starts a new session, state resets
@@ -368,6 +380,7 @@ Custom tools can provide `renderCall` and `renderResult` methods to control how
### How It Works
Tool output is wrapped in a `Box` component that handles:
+
- Padding (1 character horizontal, 1 line vertical)
- Background color based on state (pending/success/error)
@@ -389,6 +402,7 @@ renderCall(args, theme) {
```
Called when:
+
- Tool call starts (may have partial args during streaming)
- Args are updated during streaming
@@ -412,7 +426,7 @@ renderResult(result, { expanded, isPartial }, theme) {
// Normal result
let text = theme.fg("success", "✓ ") + theme.fg("muted", "Done");
-
+
// Support expanded view (Ctrl+O)
if (expanded && details?.items) {
for (const item of details.items) {
@@ -425,6 +439,7 @@ renderResult(result, { expanded, isPartial }, theme) {
```
**Options:**
+
- `expanded`: User pressed Ctrl+O to expand
- `isPartial`: Result is from `onUpdate` (streaming), not final
@@ -441,23 +456,24 @@ renderResult(result, { expanded, isPartial }, theme) {
```typescript
// Foreground
-theme.fg("toolTitle", text) // Tool names
-theme.fg("accent", text) // Highlights
-theme.fg("success", text) // Success
-theme.fg("error", text) // Errors
-theme.fg("warning", text) // Warnings
-theme.fg("muted", text) // Secondary text
-theme.fg("dim", text) // Tertiary text
-theme.fg("toolOutput", text) // Output content
+theme.fg("toolTitle", text); // Tool names
+theme.fg("accent", text); // Highlights
+theme.fg("success", text); // Success
+theme.fg("error", text); // Errors
+theme.fg("warning", text); // Warnings
+theme.fg("muted", text); // Secondary text
+theme.fg("dim", text); // Tertiary text
+theme.fg("toolOutput", text); // Output content
// Styles
-theme.bold(text)
-theme.italic(text)
+theme.bold(text);
+theme.italic(text);
```
### Fallback Behavior
If `renderCall` or `renderResult` is not defined or throws an error:
+
- `renderCall`: Shows tool name
- `renderResult`: Shows raw text output from `content`
@@ -513,11 +529,13 @@ const factory: CustomToolFactory = (pi) => {
## Examples
See [`examples/custom-tools/todo/index.ts`](../examples/custom-tools/todo/index.ts) for a complete example with:
+
- `onSession` for state reconstruction
- Custom `renderCall` and `renderResult`
- Proper branching support via details storage
Test with:
+
```bash
pi --tool packages/coding-agent/examples/custom-tools/todo/index.ts
```
diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md
index 9f5f9ed04..a98fa3649 100644
--- a/packages/coding-agent/docs/hooks.md
+++ b/packages/coding-agent/docs/hooks.md
@@ -5,6 +5,7 @@
Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecycle events. They can intercept tool calls, prompt the user, modify results, inject messages, and more.
**Key capabilities:**
+
- **User interaction** - Hooks can prompt users via `ctx.ui` (select, confirm, input, notify)
- **Custom UI components** - Full TUI components with keyboard input via `ctx.ui.custom()`
- **Custom slash commands** - Register commands like `/mycommand` via `pi.registerCommand()`
@@ -12,6 +13,7 @@ Hooks are TypeScript modules that extend pi's behavior by subscribing to lifecyc
- **Session persistence** - Store hook state that survives restarts via `pi.appendEntry()`
**Example use cases:**
+
- Permission gates (confirm before `rm -rf`, `sudo`, etc.)
- Git checkpointing (stash at each turn, restore on `/branch`)
- Path protection (block writes to `.env`, `node_modules/`)
@@ -25,19 +27,19 @@ See [examples/hooks/](../examples/hooks/) for working implementations, including
Create `~/.pi/agent/hooks/my-hook.ts`:
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
- pi.on("session_start", async (_event, ctx) => {
- ctx.ui.notify("Hook loaded!", "info");
- });
+ pi.on("session_start", async (_event, ctx) => {
+ ctx.ui.notify("Hook loaded!", "info");
+ });
- 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" };
- }
- });
+ 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" };
+ }
+ });
}
```
@@ -51,27 +53,27 @@ pi --hook ./my-hook.ts
Hooks are auto-discovered from:
-| Location | Scope |
-|----------|-------|
+| Location | Scope |
+| ------------------------ | --------------------- |
| `~/.pi/agent/hooks/*.ts` | Global (all projects) |
-| `.pi/hooks/*.ts` | Project-local |
+| `.pi/hooks/*.ts` | Project-local |
Additional paths via `settings.json`:
```json
{
- "hooks": ["/path/to/hook.ts"]
+ "hooks": ["/path/to/hook.ts"]
}
```
## Available Imports
-| Package | Purpose |
-|---------|---------|
-| `@mariozechner/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
-| `@mariozechner/pi-coding-agent` | Additional types if needed |
-| `@mariozechner/pi-ai` | AI utilities |
-| `@mariozechner/pi-tui` | TUI components |
+| Package | Purpose |
+| --------------------------------- | --------------------------------------------- |
+| `@oh-my-pi/pi-coding-agent/hooks` | Hook types (`HookAPI`, `HookContext`, events) |
+| `@oh-my-pi/pi-coding-agent` | Additional types if needed |
+| `@oh-my-pi/pi-ai` | AI utilities |
+| `@oh-my-pi/pi-tui` | TUI components |
Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
@@ -80,13 +82,13 @@ Node.js built-ins (`node:fs`, `node:path`, etc.) are also available.
A hook exports a default function that receives `HookAPI`:
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
- // Subscribe to events
- pi.on("event_name", async (event, ctx) => {
- // Handle event
- });
+ // Subscribe to events
+ pi.on("event_name", async (event, ctx) => {
+ // Handle event
+ });
}
```
@@ -151,7 +153,7 @@ Fired on initial session load.
```typescript
pi.on("session_start", async (_event, ctx) => {
- ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
+ ctx.ui.notify(`Session: ${ctx.sessionManager.getSessionFile() ?? "ephemeral"}`, "info");
});
```
@@ -161,20 +163,20 @@ Fired when starting a new session (`/new`) or switching sessions (`/resume`).
```typescript
pi.on("session_before_switch", async (event, ctx) => {
- // event.reason - "new" (starting fresh) or "resume" (switching to existing)
- // event.targetSessionFile - session we're switching to (only for "resume")
-
- if (event.reason === "new") {
- const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
- if (!ok) return { cancel: true };
- }
-
- return { cancel: true }; // Cancel the switch/new
+ // event.reason - "new" (starting fresh) or "resume" (switching to existing)
+ // event.targetSessionFile - session we're switching to (only for "resume")
+
+ if (event.reason === "new") {
+ const ok = await ctx.ui.confirm("Clear?", "Delete all messages?");
+ if (!ok) return { cancel: true };
+ }
+
+ return { cancel: true }; // Cancel the switch/new
});
pi.on("session_switch", async (event, ctx) => {
- // event.reason - "new" or "resume"
- // event.previousSessionFile - session we came from
+ // event.reason - "new" or "resume"
+ // event.previousSessionFile - session we came from
});
```
@@ -184,15 +186,15 @@ Fired when branching via `/branch`.
```typescript
pi.on("session_before_branch", async (event, ctx) => {
- // event.entryId - ID of the entry being branched from
+ // event.entryId - ID of the entry being branched from
- return { cancel: true }; // Cancel branch
- // OR
- return { skipConversationRestore: true }; // Branch but don't rewind messages
+ return { cancel: true }; // Cancel branch
+ // OR
+ return { skipConversationRestore: true }; // Branch but don't rewind messages
});
pi.on("session_branch", async (event, ctx) => {
- // event.previousSessionFile - previous session file
+ // event.previousSessionFile - previous session file
});
```
@@ -204,24 +206,24 @@ Fired on compaction. See [compaction.md](compaction.md) for details.
```typescript
pi.on("session_before_compact", async (event, ctx) => {
- const { preparation, branchEntries, customInstructions, signal } = event;
+ const { preparation, branchEntries, customInstructions, signal } = event;
- // Cancel:
- return { cancel: true };
+ // Cancel:
+ return { cancel: true };
- // Custom summary:
- return {
- compaction: {
- summary: "...",
- firstKeptEntryId: preparation.firstKeptEntryId,
- tokensBefore: preparation.tokensBefore,
- }
- };
+ // Custom summary:
+ return {
+ compaction: {
+ summary: "...",
+ firstKeptEntryId: preparation.firstKeptEntryId,
+ tokensBefore: preparation.tokensBefore,
+ },
+ };
});
pi.on("session_compact", async (event, ctx) => {
- // event.compactionEntry - the saved compaction
- // event.fromHook - whether hook provided it
+ // event.compactionEntry - the saved compaction
+ // event.fromHook - whether hook provided it
});
```
@@ -231,17 +233,17 @@ Fired on `/tree` navigation. Always fires regardless of user's summarization cho
```typescript
pi.on("session_before_tree", async (event, ctx) => {
- const { preparation, signal } = event;
- // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize
- // preparation.userWantsSummary - whether user chose to summarize
+ const { preparation, signal } = event;
+ // preparation.targetId, oldLeafId, commonAncestorId, entriesToSummarize
+ // preparation.userWantsSummary - whether user chose to summarize
- return { cancel: true };
- // OR provide custom summary (only used if userWantsSummary is true):
- return { summary: { summary: "...", details: {} } };
+ return { cancel: true };
+ // OR provide custom summary (only used if userWantsSummary is true):
+ return { summary: { summary: "...", details: {} } };
});
pi.on("session_tree", async (event, ctx) => {
- // event.newLeafId, oldLeafId, summaryEntry, fromHook
+ // event.newLeafId, oldLeafId, summaryEntry, fromHook
});
```
@@ -251,7 +253,7 @@ Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
```typescript
pi.on("session_shutdown", async (_event, ctx) => {
- // Cleanup, save state, etc.
+ // Cleanup, save state, etc.
});
```
@@ -263,16 +265,16 @@ Fired after user submits prompt, before agent loop. Can inject a persistent mess
```typescript
pi.on("before_agent_start", async (event, ctx) => {
- // event.prompt - user's prompt text
- // event.images - attached images (if any)
+ // event.prompt - user's prompt text
+ // event.images - attached images (if any)
- return {
- message: {
- customType: "my-hook",
- content: "Additional context for the LLM",
- display: true, // Show in TUI
- }
- };
+ return {
+ message: {
+ customType: "my-hook",
+ content: "Additional context for the LLM",
+ display: true, // Show in TUI
+ },
+ };
});
```
@@ -286,7 +288,7 @@ Fired once per user prompt.
pi.on("agent_start", async (_event, ctx) => {});
pi.on("agent_end", async (event, ctx) => {
- // event.messages - messages from this prompt
+ // event.messages - messages from this prompt
});
```
@@ -296,13 +298,13 @@ Fired for each turn (one LLM response + tool calls).
```typescript
pi.on("turn_start", async (event, ctx) => {
- // event.turnIndex, event.timestamp
+ // event.turnIndex, event.timestamp
});
pi.on("turn_end", async (event, ctx) => {
- // event.turnIndex
- // event.message - assistant's response
- // event.toolResults - tool results from this turn
+ // event.turnIndex
+ // event.message - assistant's response
+ // event.toolResults - tool results from this turn
});
```
@@ -312,11 +314,11 @@ Fired before each LLM call. Modify messages non-destructively (session unchanged
```typescript
pi.on("context", async (event, ctx) => {
- // event.messages - deep copy, safe to modify
+ // event.messages - deep copy, safe to modify
- // Filter or transform messages
- const filtered = event.messages.filter(m => !shouldPrune(m));
- return { messages: filtered };
+ // Filter or transform messages
+ const filtered = event.messages.filter((m) => !shouldPrune(m));
+ return { messages: filtered };
});
```
@@ -328,17 +330,18 @@ Fired before tool executes. **Can block.**
```typescript
pi.on("tool_call", async (event, ctx) => {
- // event.toolName - "bash", "read", "write", "edit", etc.
- // event.toolCallId
- // event.input - tool parameters
+ // event.toolName - "bash", "read", "write", "edit", etc.
+ // event.toolCallId
+ // event.input - tool parameters
- if (shouldBlock(event)) {
- return { block: true, reason: "Not allowed" };
- }
+ if (shouldBlock(event)) {
+ return { block: true, reason: "Not allowed" };
+ }
});
```
Tool inputs:
+
- `bash`: `{ command, timeout? }`
- `read`: `{ path, offset?, limit? }`
- `write`: `{ path, content }`
@@ -372,15 +375,15 @@ pi.on("tool_result", async (event, ctx) => {
Use type guards for typed details:
```typescript
-import { isBashToolResult } from "@mariozechner/pi-coding-agent";
+import { isBashToolResult } from "@oh-my-pi/pi-coding-agent";
pi.on("tool_result", async (event, ctx) => {
- if (isBashToolResult(event)) {
- // event.details is BashToolDetails | undefined
- if (event.details?.truncation?.truncated) {
- // Full output at event.details.fullOutputPath
- }
- }
+ if (isBashToolResult(event)) {
+ // event.details is BashToolDetails | undefined
+ if (event.details?.truncation?.truncated) {
+ // Full output at event.details.fullOutputPath
+ }
+ }
});
```
@@ -415,11 +418,11 @@ const text = await ctx.ui.editor("Edit prompt:", "prefilled text");
// Ctrl+Enter to submit, Ctrl+G to open $VISUAL or $EDITOR
// Notification (non-blocking)
-ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
+ctx.ui.notify("Done!", "info"); // "info" | "warning" | "error"
// Set status text in footer (persistent until cleared)
-ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
-ctx.ui.setStatus("my-hook", undefined); // Clear status
+ctx.ui.setStatus("my-hook", "Processing 5/10..."); // Set status
+ctx.ui.setStatus("my-hook", undefined); // Clear status
// Set the core input editor text (pre-fill prompts, generated content)
ctx.ui.setEditorText("Generated prompt text here...");
@@ -429,6 +432,7 @@ const currentText = ctx.ui.getEditorText();
```
**Status text notes:**
+
- Multiple hooks can set their own status using unique keys
- Statuses are displayed on a single line in the footer, sorted alphabetically by key
- Text is sanitized (newlines/tabs replaced with spaces) and truncated to terminal width
@@ -457,19 +461,22 @@ See [examples/hooks/status-line.ts](../examples/hooks/status-line.ts) for a comp
Show a custom TUI component with keyboard focus:
```typescript
-import { BorderedLoader } from "@mariozechner/pi-coding-agent";
+import { BorderedLoader } from "@oh-my-pi/pi-coding-agent";
const result = await ctx.ui.custom((tui, theme, done) => {
- const loader = new BorderedLoader(tui, theme, "Working...");
- loader.onAbort = () => done(null);
-
- doWork(loader.signal).then(done).catch(() => done(null));
-
- return loader;
+ const loader = new BorderedLoader(tui, theme, "Working...");
+ loader.onAbort = () => done(null);
+
+ doWork(loader.signal)
+ .then(done)
+ .catch(() => done(null));
+
+ return loader;
});
```
Your component can:
+
- Implement `handleInput(data: string)` to receive keyboard input
- Implement `render(width: number): string[]` to render lines
- Implement `invalidate()` to clear cached render
@@ -501,23 +508,23 @@ Read-only access to session state. See `ReadonlySessionManager` in [`src/core/se
```typescript
// Session info
-ctx.sessionManager.getCwd() // Working directory
-ctx.sessionManager.getSessionDir() // Session directory (~/.pi/agent/sessions)
-ctx.sessionManager.getSessionId() // Current session ID
-ctx.sessionManager.getSessionFile() // Session file path (undefined with --no-session)
+ctx.sessionManager.getCwd(); // Working directory
+ctx.sessionManager.getSessionDir(); // Session directory (~/.pi/agent/sessions)
+ctx.sessionManager.getSessionId(); // Current session ID
+ctx.sessionManager.getSessionFile(); // Session file path (undefined with --no-session)
// Entries
-ctx.sessionManager.getEntries() // All entries (excludes header)
-ctx.sessionManager.getHeader() // Session header entry
-ctx.sessionManager.getEntry(id) // Specific entry by ID
-ctx.sessionManager.getLabel(id) // Entry label (if any)
+ctx.sessionManager.getEntries(); // All entries (excludes header)
+ctx.sessionManager.getHeader(); // Session header entry
+ctx.sessionManager.getEntry(id); // Specific entry by ID
+ctx.sessionManager.getLabel(id); // Entry label (if any)
// Tree navigation
-ctx.sessionManager.getBranch() // Current branch (root to leaf)
-ctx.sessionManager.getBranch(leafId) // Specific branch
-ctx.sessionManager.getTree() // Full tree structure
-ctx.sessionManager.getLeafId() // Current leaf entry ID
-ctx.sessionManager.getLeafEntry() // Current leaf entry
+ctx.sessionManager.getBranch(); // Current branch (root to leaf)
+ctx.sessionManager.getBranch(leafId); // Specific branch
+ctx.sessionManager.getTree(); // Full tree structure
+ctx.sessionManager.getLeafId(); // Current leaf entry ID
+ctx.sessionManager.getLeafEntry(); // Current leaf entry
```
Use `pi.sendMessage()` or `pi.appendEntry()` for writes.
@@ -540,8 +547,8 @@ Current model, or `undefined` if none selected yet. Use for LLM calls in hooks:
```typescript
if (ctx.model) {
- const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
- // Use with @mariozechner/pi-ai complete()
+ const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
+ // Use with @oh-my-pi/pi-ai complete()
}
```
@@ -551,7 +558,7 @@ Returns `true` if the agent is not currently streaming:
```typescript
if (ctx.isIdle()) {
- // Agent is not processing
+ // Agent is not processing
}
```
@@ -569,8 +576,8 @@ Check if there are messages queued (user typed while agent was streaming):
```typescript
if (ctx.hasQueuedMessages()) {
- // Skip interactive prompt, let queued message take over
- return;
+ // Skip interactive prompt, let queued message take over
+ return;
}
```
@@ -593,19 +600,19 @@ Create a new session, optionally with initialization:
```typescript
const result = await ctx.newSession({
- parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
- setup: async (sm) => {
- // Initialize the new session
- sm.appendMessage({
- role: "user",
- content: [{ type: "text", text: "Context from previous session..." }],
- timestamp: Date.now(),
- });
- },
+ parentSession: ctx.sessionManager.getSessionFile(), // Track lineage
+ setup: async (sm) => {
+ // Initialize the new session
+ sm.appendMessage({
+ role: "user",
+ content: [{ type: "text", text: "Context from previous session..." }],
+ timestamp: Date.now(),
+ });
+ },
});
if (result.cancelled) {
- // A hook cancelled the new session
+ // A hook cancelled the new session
}
```
@@ -616,7 +623,7 @@ Branch from a specific entry, creating a new session file:
```typescript
const result = await ctx.branch("entry-id-123");
if (!result.cancelled) {
- // Now in the branched session
+ // Now in the branched session
}
```
@@ -626,7 +633,7 @@ Navigate to a different point in the session tree:
```typescript
const result = await ctx.navigateTree("entry-id-456", {
- summarize: true, // Summarize the abandoned branch
+ summarize: true, // Summarize the abandoned branch
});
```
@@ -650,15 +657,18 @@ pi.sendMessage({
```
**Storage and timing:**
+
- The message is appended to the session file immediately as a `CustomMessageEntry`
- If the agent is currently streaming, the message is queued and appended after the current turn
- If `triggerTurn` is true and the agent is idle, a new agent loop starts
**LLM context:**
+
- `CustomMessageEntry` is converted to a user message when building context for the LLM
- Only `content` is sent to the LLM; `details` is for rendering/state only
**TUI display:**
+
- If `display: true`, the message appears in the chat with purple styling (customMessageBg, customMessageText, customMessageLabel theme colors)
- If `display: false`, the message is hidden from the TUI but still sent to the LLM
- Use `pi.registerMessageRenderer()` to customize how your messages render (see below)
@@ -673,11 +683,11 @@ pi.appendEntry("my-hook-state", { count: 42 });
// Restore on reload
pi.on("session_start", async (_event, ctx) => {
- for (const entry of ctx.sessionManager.getEntries()) {
- if (entry.type === "custom" && entry.customType === "my-hook-state") {
- // Reconstruct from entry.data
- }
- }
+ for (const entry of ctx.sessionManager.getEntries()) {
+ if (entry.type === "custom" && entry.customType === "my-hook-state") {
+ // Reconstruct from entry.data
+ }
+ }
});
```
@@ -687,12 +697,12 @@ Register a custom slash command:
```typescript
pi.registerCommand("stats", {
- description: "Show session statistics",
- handler: async (args, ctx) => {
- // args = everything after /stats
- const count = ctx.sessionManager.getEntries().length;
- ctx.ui.notify(`${count} entries`, "info");
- }
+ description: "Show session statistics",
+ handler: async (args, ctx) => {
+ // args = everything after /stats
+ const count = ctx.sessionManager.getEntries().length;
+ ctx.ui.notify(`${count} entries`, "info");
+ },
});
```
@@ -705,28 +715,30 @@ To trigger LLM after command, call `pi.sendMessage(..., true)`.
Register a custom TUI renderer for `CustomMessageEntry` messages with your `customType`. Without a custom renderer, messages display with default purple styling showing the content as-is.
```typescript
-import { Text } from "@mariozechner/pi-tui";
+import { Text } from "@oh-my-pi/pi-tui";
pi.registerMessageRenderer("my-hook", (message, options, theme) => {
- // message.content - the message content (string or content array)
- // message.details - your custom metadata
- // options.expanded - true if user pressed Ctrl+O
-
- const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `);
- const text = typeof message.content === "string"
- ? message.content
- : message.content.map(c => c.type === "text" ? c.text : "[image]").join("");
-
- return new Text(prefix + theme.fg("text", text), 0, 0);
+ // message.content - the message content (string or content array)
+ // message.details - your custom metadata
+ // options.expanded - true if user pressed Ctrl+O
+
+ const prefix = theme.fg("accent", `[${message.details?.label ?? "INFO"}] `);
+ const text =
+ typeof message.content === "string"
+ ? message.content
+ : message.content.map((c) => (c.type === "text" ? c.text : "[image]")).join("");
+
+ return new Text(prefix + theme.fg("text", text), 0, 0);
});
```
**Renderer signature:**
+
```typescript
type HookMessageRenderer = (
- message: CustomMessageEntry,
- options: { expanded: boolean },
- theme: Theme
+ message: CustomMessageEntry,
+ options: { expanded: boolean },
+ theme: Theme
) => Component | null;
```
@@ -738,8 +750,8 @@ Execute a shell command:
```typescript
const result = await pi.exec("git", ["status"], {
- signal, // AbortSignal
- timeout, // Milliseconds
+ signal, // AbortSignal
+ timeout, // Milliseconds
});
// result.stdout, result.stderr, result.code, result.killed
@@ -750,79 +762,79 @@ const result = await pi.exec("git", ["status"], {
### Permission Gate
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
- const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
+ const dangerous = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i];
- pi.on("tool_call", async (event, ctx) => {
- if (event.toolName !== "bash") return;
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName !== "bash") return;
- const cmd = event.input.command as string;
- if (dangerous.some(p => p.test(cmd))) {
- if (!ctx.hasUI) {
- return { block: true, reason: "Dangerous (no UI)" };
- }
- const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
- if (!ok) return { block: true, reason: "Blocked by user" };
- }
- });
+ const cmd = event.input.command as string;
+ if (dangerous.some((p) => p.test(cmd))) {
+ if (!ctx.hasUI) {
+ return { block: true, reason: "Dangerous (no UI)" };
+ }
+ const ok = await ctx.ui.confirm("Dangerous!", `Allow: ${cmd}?`);
+ if (!ok) return { block: true, reason: "Blocked by user" };
+ }
+ });
}
```
### Protected Paths
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
- const protectedPaths = [".env", ".git/", "node_modules/"];
+ const protectedPaths = [".env", ".git/", "node_modules/"];
- pi.on("tool_call", async (event, ctx) => {
- if (event.toolName !== "write" && event.toolName !== "edit") return;
+ pi.on("tool_call", async (event, ctx) => {
+ if (event.toolName !== "write" && event.toolName !== "edit") return;
- const path = event.input.path as string;
- if (protectedPaths.some(p => path.includes(p))) {
- ctx.ui.notify(`Blocked: ${path}`, "warning");
- return { block: true, reason: `Protected: ${path}` };
- }
- });
+ const path = event.input.path as string;
+ if (protectedPaths.some((p) => path.includes(p))) {
+ ctx.ui.notify(`Blocked: ${path}`, "warning");
+ return { block: true, reason: `Protected: ${path}` };
+ }
+ });
}
```
### Git Checkpoint
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
- const checkpoints = new Map();
- let currentEntryId: string | undefined;
+ const checkpoints = new Map();
+ let currentEntryId: string | undefined;
- pi.on("tool_result", async (_event, ctx) => {
- const leaf = ctx.sessionManager.getLeafEntry();
- if (leaf) currentEntryId = leaf.id;
- });
+ pi.on("tool_result", async (_event, ctx) => {
+ const leaf = ctx.sessionManager.getLeafEntry();
+ if (leaf) currentEntryId = leaf.id;
+ });
- pi.on("turn_start", async () => {
- const { stdout } = await pi.exec("git", ["stash", "create"]);
- if (stdout.trim() && currentEntryId) {
- checkpoints.set(currentEntryId, stdout.trim());
- }
- });
+ pi.on("turn_start", async () => {
+ const { stdout } = await pi.exec("git", ["stash", "create"]);
+ if (stdout.trim() && currentEntryId) {
+ checkpoints.set(currentEntryId, stdout.trim());
+ }
+ });
- pi.on("session_before_branch", async (event, ctx) => {
- const ref = checkpoints.get(event.entryId);
- if (!ref || !ctx.hasUI) return;
+ pi.on("session_before_branch", async (event, ctx) => {
+ const ref = checkpoints.get(event.entryId);
+ if (!ref || !ctx.hasUI) return;
- const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?");
- if (ok) {
- await pi.exec("git", ["stash", "apply", ref]);
- ctx.ui.notify("Code restored", "info");
- }
- });
+ const ok = await ctx.ui.confirm("Restore?", "Restore code to checkpoint?");
+ if (ok) {
+ await pi.exec("git", ["stash", "apply", ref]);
+ ctx.ui.notify("Code restored", "info");
+ }
+ });
- pi.on("agent_end", () => checkpoints.clear());
+ pi.on("agent_end", () => checkpoints.clear());
}
```
@@ -832,10 +844,10 @@ See [examples/hooks/snake.ts](../examples/hooks/snake.ts) for a complete example
## Mode Behavior
-| Mode | UI Methods | Notes |
-|------|-----------|-------|
-| Interactive | Full TUI | Normal operation |
-| RPC | JSON protocol | Host handles UI |
+| Mode | UI Methods | Notes |
+| ------------ | -------------------------- | -------------------------- |
+| Interactive | Full TUI | Normal operation |
+| RPC | JSON protocol | Host handles UI |
| Print (`-p`) | No-op (returns null/false) | Hooks run but can't prompt |
In print mode, `select()` returns `undefined`, `confirm()` returns `false`, `input()` returns `undefined`, `getEditorText()` returns `""`, and `setEditorText()`/`setStatus()` are no-ops. Design hooks to handle this by checking `ctx.hasUI`.
diff --git a/packages/coding-agent/docs/rpc.md b/packages/coding-agent/docs/rpc.md
index 07db6acb1..d10fcca78 100644
--- a/packages/coding-agent/docs/rpc.md
+++ b/packages/coding-agent/docs/rpc.md
@@ -2,7 +2,7 @@
RPC mode enables headless operation of the coding agent via a JSON protocol over stdin/stdout. This is useful for embedding the agent in other applications, IDEs, or custom UIs.
-**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@mariozechner/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).
+**Note for Node.js/TypeScript users**: If you're building a Node.js application, consider using `AgentSession` directly from `@oh-my-pi/pi-coding-agent` instead of spawning a subprocess. See [`src/core/agent-session.ts`](../src/core/agent-session.ts) for the API. For a subprocess-based TypeScript client, see [`src/modes/rpc/rpc-client.ts`](../src/modes/rpc/rpc-client.ts).
## Starting RPC Mode
@@ -11,6 +11,7 @@ pi --mode rpc [options]
```
Common options:
+
- `--provider `: Set the LLM provider (anthropic, openai, google, etc.)
- `--model `: Set the model ID
- `--no-session`: Disable session persistence
@@ -33,17 +34,23 @@ All commands support an optional `id` field for request/response correlation. If
Send a user prompt to the agent. Returns immediately; events stream asynchronously.
```json
-{"id": "req-1", "type": "prompt", "message": "Hello, world!"}
+{ "id": "req-1", "type": "prompt", "message": "Hello, world!" }
```
With images:
+
```json
-{"type": "prompt", "message": "What's in this image?", "images": [{"type": "image", "source": {"type": "base64", "mediaType": "image/png", "data": "..."}}]}
+{
+ "type": "prompt",
+ "message": "What's in this image?",
+ "images": [{ "type": "image", "source": { "type": "base64", "mediaType": "image/png", "data": "..." } }]
+}
```
Response:
+
```json
-{"id": "req-1", "type": "response", "command": "prompt", "success": true}
+{ "id": "req-1", "type": "response", "command": "prompt", "success": true }
```
The `images` field is optional. Each image uses `ImageContent` format with base64 or URL source.
@@ -53,12 +60,13 @@ The `images` field is optional. Each image uses `ImageContent` format with base6
Queue a message to be injected at the next agent turn. Queued messages are added to the conversation without triggering a new prompt. Useful for injecting context mid-conversation.
```json
-{"type": "queue_message", "message": "Additional context"}
+{ "type": "queue_message", "message": "Additional context" }
```
Response:
+
```json
-{"type": "response", "command": "queue_message", "success": true}
+{ "type": "response", "command": "queue_message", "success": true }
```
See [set_queue_mode](#set_queue_mode) for controlling how queued messages are processed.
@@ -68,12 +76,13 @@ See [set_queue_mode](#set_queue_mode) for controlling how queued messages are pr
Abort the current agent operation.
```json
-{"type": "abort"}
+{ "type": "abort" }
```
Response:
+
```json
-{"type": "response", "command": "abort", "success": true}
+{ "type": "response", "command": "abort", "success": true }
```
#### new_session
@@ -81,22 +90,25 @@ Response:
Start a fresh session. Can be cancelled by a `session_before_switch` hook.
```json
-{"type": "new_session"}
+{ "type": "new_session" }
```
With optional parent session tracking:
+
```json
-{"type": "new_session", "parentSession": "/path/to/parent-session.jsonl"}
+{ "type": "new_session", "parentSession": "/path/to/parent-session.jsonl" }
```
Response:
+
```json
-{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": false}}
+{ "type": "response", "command": "new_session", "success": true, "data": { "cancelled": false } }
```
If a hook cancelled:
+
```json
-{"type": "response", "command": "new_session", "success": true, "data": {"cancelled": true}}
+{ "type": "response", "command": "new_session", "success": true, "data": { "cancelled": true } }
```
### State
@@ -106,10 +118,11 @@ If a hook cancelled:
Get current session state.
```json
-{"type": "get_state"}
+{ "type": "get_state" }
```
Response:
+
```json
{
"type": "response",
@@ -137,10 +150,11 @@ The `model` field is a full [Model](#model) object or `null`.
Get all messages in the conversation.
```json
-{"type": "get_messages"}
+{ "type": "get_messages" }
```
Response:
+
```json
{
"type": "response",
@@ -159,10 +173,11 @@ Messages are `AgentMessage` objects (see [Message Types](#message-types)).
Switch to a specific model.
```json
-{"type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4-20250514"}
+{ "type": "set_model", "provider": "anthropic", "modelId": "claude-sonnet-4-20250514" }
```
Response contains the full [Model](#model) object:
+
```json
{
"type": "response",
@@ -177,10 +192,11 @@ Response contains the full [Model](#model) object:
Cycle to the next available model. Returns `null` data if only one model available.
```json
-{"type": "cycle_model"}
+{ "type": "cycle_model" }
```
Response:
+
```json
{
"type": "response",
@@ -201,10 +217,11 @@ The `model` field is a full [Model](#model) object.
List all configured models.
```json
-{"type": "get_available_models"}
+{ "type": "get_available_models" }
```
Response contains an array of full [Model](#model) objects:
+
```json
{
"type": "response",
@@ -223,7 +240,7 @@ Response contains an array of full [Model](#model) objects:
Set the reasoning/thinking level for models that support it.
```json
-{"type": "set_thinking_level", "level": "high"}
+{ "type": "set_thinking_level", "level": "high" }
```
Levels: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`
@@ -231,8 +248,9 @@ Levels: `"off"`, `"minimal"`, `"low"`, `"medium"`, `"high"`, `"xhigh"`
Note: `"xhigh"` is only supported by OpenAI codex-max models.
Response:
+
```json
-{"type": "response", "command": "set_thinking_level", "success": true}
+{ "type": "response", "command": "set_thinking_level", "success": true }
```
#### cycle_thinking_level
@@ -240,16 +258,17 @@ Response:
Cycle through available thinking levels. Returns `null` data if model doesn't support thinking.
```json
-{"type": "cycle_thinking_level"}
+{ "type": "cycle_thinking_level" }
```
Response:
+
```json
{
- "type": "response",
- "command": "cycle_thinking_level",
- "success": true,
- "data": {"level": "high"}
+ "type": "response",
+ "command": "cycle_thinking_level",
+ "success": true,
+ "data": { "level": "high" }
}
```
@@ -260,16 +279,18 @@ Response:
Control how queued messages (from `queue_message`) are injected into the conversation.
```json
-{"type": "set_queue_mode", "mode": "one-at-a-time"}
+{ "type": "set_queue_mode", "mode": "one-at-a-time" }
```
Modes:
+
- `"all"`: Inject all queued messages at the next turn
- `"one-at-a-time"`: Inject one queued message per turn (default)
Response:
+
```json
-{"type": "response", "command": "set_queue_mode", "success": true}
+{ "type": "response", "command": "set_queue_mode", "success": true }
```
### Compaction
@@ -279,26 +300,28 @@ Response:
Manually compact conversation context to reduce token usage.
```json
-{"type": "compact"}
+{ "type": "compact" }
```
With custom instructions:
+
```json
-{"type": "compact", "customInstructions": "Focus on code changes"}
+{ "type": "compact", "customInstructions": "Focus on code changes" }
```
Response:
+
```json
{
- "type": "response",
- "command": "compact",
- "success": true,
- "data": {
- "summary": "Summary of conversation...",
- "firstKeptEntryId": "abc123",
- "tokensBefore": 150000,
- "details": {}
- }
+ "type": "response",
+ "command": "compact",
+ "success": true,
+ "data": {
+ "summary": "Summary of conversation...",
+ "firstKeptEntryId": "abc123",
+ "tokensBefore": 150000,
+ "details": {}
+ }
}
```
@@ -307,12 +330,13 @@ Response:
Enable or disable automatic compaction when context is nearly full.
```json
-{"type": "set_auto_compaction", "enabled": true}
+{ "type": "set_auto_compaction", "enabled": true }
```
Response:
+
```json
-{"type": "response", "command": "set_auto_compaction", "success": true}
+{ "type": "response", "command": "set_auto_compaction", "success": true }
```
### Retry
@@ -322,12 +346,13 @@ Response:
Enable or disable automatic retry on transient errors (overloaded, rate limit, 5xx).
```json
-{"type": "set_auto_retry", "enabled": true}
+{ "type": "set_auto_retry", "enabled": true }
```
Response:
+
```json
-{"type": "response", "command": "set_auto_retry", "success": true}
+{ "type": "response", "command": "set_auto_retry", "success": true }
```
#### abort_retry
@@ -335,12 +360,13 @@ Response:
Abort an in-progress retry (cancel the delay and stop retrying).
```json
-{"type": "abort_retry"}
+{ "type": "abort_retry" }
```
Response:
+
```json
-{"type": "response", "command": "abort_retry", "success": true}
+{ "type": "response", "command": "abort_retry", "success": true }
```
### Bash
@@ -350,37 +376,39 @@ Response:
Execute a shell command and add output to conversation context.
```json
-{"type": "bash", "command": "ls -la"}
+{ "type": "bash", "command": "ls -la" }
```
Response:
+
```json
{
- "type": "response",
- "command": "bash",
- "success": true,
- "data": {
- "output": "total 48\ndrwxr-xr-x ...",
- "exitCode": 0,
- "cancelled": false,
- "truncated": false
- }
+ "type": "response",
+ "command": "bash",
+ "success": true,
+ "data": {
+ "output": "total 48\ndrwxr-xr-x ...",
+ "exitCode": 0,
+ "cancelled": false,
+ "truncated": false
+ }
}
```
If output was truncated, includes `fullOutputPath`:
+
```json
{
- "type": "response",
- "command": "bash",
- "success": true,
- "data": {
- "output": "truncated output...",
- "exitCode": 0,
- "cancelled": false,
- "truncated": true,
- "fullOutputPath": "/tmp/pi-bash-abc123.log"
- }
+ "type": "response",
+ "command": "bash",
+ "success": true,
+ "data": {
+ "output": "truncated output...",
+ "exitCode": 0,
+ "cancelled": false,
+ "truncated": true,
+ "fullOutputPath": "/tmp/pi-bash-abc123.log"
+ }
}
```
@@ -399,6 +427,7 @@ drwxr-xr-x ...
```
This means:
+
1. Bash output is included in the LLM context on the **next prompt**, not immediately
2. Multiple bash commands can be executed before a prompt; all outputs will be included
3. No event is emitted for the `BashExecutionMessage` itself
@@ -408,12 +437,13 @@ This means:
Abort a running bash command.
```json
-{"type": "abort_bash"}
+{ "type": "abort_bash" }
```
Response:
+
```json
-{"type": "response", "command": "abort_bash", "success": true}
+{ "type": "response", "command": "abort_bash", "success": true }
```
### Session
@@ -423,32 +453,33 @@ Response:
Get token usage and cost statistics.
```json
-{"type": "get_session_stats"}
+{ "type": "get_session_stats" }
```
Response:
+
```json
{
- "type": "response",
- "command": "get_session_stats",
- "success": true,
- "data": {
- "sessionFile": "/path/to/session.jsonl",
- "sessionId": "abc123",
- "userMessages": 5,
- "assistantMessages": 5,
- "toolCalls": 12,
- "toolResults": 12,
- "totalMessages": 22,
- "tokens": {
- "input": 50000,
- "output": 10000,
- "cacheRead": 40000,
- "cacheWrite": 5000,
- "total": 105000
- },
- "cost": 0.45
- }
+ "type": "response",
+ "command": "get_session_stats",
+ "success": true,
+ "data": {
+ "sessionFile": "/path/to/session.jsonl",
+ "sessionId": "abc123",
+ "userMessages": 5,
+ "assistantMessages": 5,
+ "toolCalls": 12,
+ "toolResults": 12,
+ "totalMessages": 22,
+ "tokens": {
+ "input": 50000,
+ "output": 10000,
+ "cacheRead": 40000,
+ "cacheWrite": 5000,
+ "total": 105000
+ },
+ "cost": 0.45
+ }
}
```
@@ -457,21 +488,23 @@ Response:
Export session to an HTML file.
```json
-{"type": "export_html"}
+{ "type": "export_html" }
```
With custom path:
+
```json
-{"type": "export_html", "outputPath": "/tmp/session.html"}
+{ "type": "export_html", "outputPath": "/tmp/session.html" }
```
Response:
+
```json
{
- "type": "response",
- "command": "export_html",
- "success": true,
- "data": {"path": "/tmp/session.html"}
+ "type": "response",
+ "command": "export_html",
+ "success": true,
+ "data": { "path": "/tmp/session.html" }
}
```
@@ -480,17 +513,19 @@ Response:
Load a different session file. Can be cancelled by a `before_switch` hook.
```json
-{"type": "switch_session", "sessionPath": "/path/to/session.jsonl"}
+{ "type": "switch_session", "sessionPath": "/path/to/session.jsonl" }
```
Response:
+
```json
-{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": false}}
+{ "type": "response", "command": "switch_session", "success": true, "data": { "cancelled": false } }
```
If a hook cancelled the switch:
+
```json
-{"type": "response", "command": "switch_session", "success": true, "data": {"cancelled": true}}
+{ "type": "response", "command": "switch_session", "success": true, "data": { "cancelled": true } }
```
#### branch
@@ -498,26 +533,28 @@ If a hook cancelled the switch:
Create a new branch from a previous user message. Can be cancelled by a `before_branch` hook. Returns the text of the message being branched from.
```json
-{"type": "branch", "entryId": "abc123"}
+{ "type": "branch", "entryId": "abc123" }
```
Response:
+
```json
{
- "type": "response",
- "command": "branch",
- "success": true,
- "data": {"text": "The original prompt text...", "cancelled": false}
+ "type": "response",
+ "command": "branch",
+ "success": true,
+ "data": { "text": "The original prompt text...", "cancelled": false }
}
```
If a hook cancelled the branch:
+
```json
{
- "type": "response",
- "command": "branch",
- "success": true,
- "data": {"text": "The original prompt text...", "cancelled": true}
+ "type": "response",
+ "command": "branch",
+ "success": true,
+ "data": { "text": "The original prompt text...", "cancelled": true }
}
```
@@ -526,21 +563,22 @@ If a hook cancelled the branch:
Get user messages available for branching.
```json
-{"type": "get_branch_messages"}
+{ "type": "get_branch_messages" }
```
Response:
+
```json
{
- "type": "response",
- "command": "get_branch_messages",
- "success": true,
- "data": {
- "messages": [
- {"entryId": "abc123", "text": "First prompt..."},
- {"entryId": "def456", "text": "Second prompt..."}
- ]
- }
+ "type": "response",
+ "command": "get_branch_messages",
+ "success": true,
+ "data": {
+ "messages": [
+ { "entryId": "abc123", "text": "First prompt..." },
+ { "entryId": "def456", "text": "Second prompt..." }
+ ]
+ }
}
```
@@ -549,16 +587,17 @@ Response:
Get the text content of the last assistant message.
```json
-{"type": "get_last_assistant_text"}
+{ "type": "get_last_assistant_text" }
```
Response:
+
```json
{
- "type": "response",
- "command": "get_last_assistant_text",
- "success": true,
- "data": {"text": "The assistant's response..."}
+ "type": "response",
+ "command": "get_last_assistant_text",
+ "success": true,
+ "data": { "text": "The assistant's response..." }
}
```
@@ -570,30 +609,30 @@ Events are streamed to stdout as JSON lines during agent operation. Events do NO
### Event Types
-| Event | Description |
-|-------|-------------|
-| `agent_start` | Agent begins processing |
-| `agent_end` | Agent completes (includes all generated messages) |
-| `turn_start` | New turn begins |
-| `turn_end` | Turn completes (includes assistant message and tool results) |
-| `message_start` | Message begins |
-| `message_update` | Streaming update (text/thinking/toolcall deltas) |
-| `message_end` | Message completes |
-| `tool_execution_start` | Tool begins execution |
-| `tool_execution_update` | Tool execution progress (streaming output) |
-| `tool_execution_end` | Tool completes |
-| `auto_compaction_start` | Auto-compaction begins |
-| `auto_compaction_end` | Auto-compaction completes |
-| `auto_retry_start` | Auto-retry begins (after transient error) |
-| `auto_retry_end` | Auto-retry completes (success or final failure) |
-| `hook_error` | Hook threw an error |
+| Event | Description |
+| ----------------------- | ------------------------------------------------------------ |
+| `agent_start` | Agent begins processing |
+| `agent_end` | Agent completes (includes all generated messages) |
+| `turn_start` | New turn begins |
+| `turn_end` | Turn completes (includes assistant message and tool results) |
+| `message_start` | Message begins |
+| `message_update` | Streaming update (text/thinking/toolcall deltas) |
+| `message_end` | Message completes |
+| `tool_execution_start` | Tool begins execution |
+| `tool_execution_update` | Tool execution progress (streaming output) |
+| `tool_execution_end` | Tool completes |
+| `auto_compaction_start` | Auto-compaction begins |
+| `auto_compaction_end` | Auto-compaction completes |
+| `auto_retry_start` | Auto-retry begins (after transient error) |
+| `auto_retry_end` | Auto-retry completes (success or final failure) |
+| `hook_error` | Hook threw an error |
### agent_start
Emitted when the agent begins processing a prompt.
```json
-{"type": "agent_start"}
+{ "type": "agent_start" }
```
### agent_end
@@ -612,7 +651,7 @@ Emitted when the agent completes. Contains all messages generated during this ru
A turn consists of one assistant response plus any resulting tool calls and results.
```json
-{"type": "turn_start"}
+{ "type": "turn_start" }
```
```json
@@ -651,22 +690,23 @@ Emitted during streaming of assistant messages. Contains both the partial messag
The `assistantMessageEvent` field contains one of these delta types:
-| Type | Description |
-|------|-------------|
-| `start` | Message generation started |
-| `text_start` | Text content block started |
-| `text_delta` | Text content chunk |
-| `text_end` | Text content block ended |
-| `thinking_start` | Thinking block started |
-| `thinking_delta` | Thinking content chunk |
-| `thinking_end` | Thinking block ended |
-| `toolcall_start` | Tool call started |
-| `toolcall_delta` | Tool call arguments chunk |
-| `toolcall_end` | Tool call ended (includes full `toolCall` object) |
-| `done` | Message complete (reason: `"stop"`, `"length"`, `"toolUse"`) |
-| `error` | Error occurred (reason: `"aborted"`, `"error"`) |
+| Type | Description |
+| ---------------- | ------------------------------------------------------------ |
+| `start` | Message generation started |
+| `text_start` | Text content block started |
+| `text_delta` | Text content chunk |
+| `text_end` | Text content block ended |
+| `thinking_start` | Thinking block started |
+| `thinking_delta` | Thinking content chunk |
+| `thinking_end` | Thinking block ended |
+| `toolcall_start` | Tool call started |
+| `toolcall_delta` | Tool call arguments chunk |
+| `toolcall_end` | Tool call ended (includes full `toolCall` object) |
+| `done` | Message complete (reason: `"stop"`, `"length"`, `"toolUse"`) |
+| `error` | Error occurred (reason: `"aborted"`, `"error"`) |
Example streaming a text response:
+
```json
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{...}}}
{"type":"message_update","message":{...},"assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"Hello","partial":{...}}}
@@ -680,10 +720,10 @@ Emitted when a tool begins, streams progress, and completes execution.
```json
{
- "type": "tool_execution_start",
- "toolCallId": "call_abc123",
- "toolName": "bash",
- "args": {"command": "ls -la"}
+ "type": "tool_execution_start",
+ "toolCallId": "call_abc123",
+ "toolName": "bash",
+ "args": { "command": "ls -la" }
}
```
@@ -691,14 +731,14 @@ During execution, `tool_execution_update` events stream partial results (e.g., b
```json
{
- "type": "tool_execution_update",
- "toolCallId": "call_abc123",
- "toolName": "bash",
- "args": {"command": "ls -la"},
- "partialResult": {
- "content": [{"type": "text", "text": "partial output so far..."}],
- "details": {"truncation": null, "fullOutputPath": null}
- }
+ "type": "tool_execution_update",
+ "toolCallId": "call_abc123",
+ "toolName": "bash",
+ "args": { "command": "ls -la" },
+ "partialResult": {
+ "content": [{ "type": "text", "text": "partial output so far..." }],
+ "details": { "truncation": null, "fullOutputPath": null }
+ }
}
```
@@ -724,22 +764,22 @@ Use `toolCallId` to correlate events. The `partialResult` in `tool_execution_upd
Emitted when automatic compaction runs (when context is nearly full).
```json
-{"type": "auto_compaction_start", "reason": "threshold"}
+{ "type": "auto_compaction_start", "reason": "threshold" }
```
The `reason` field is `"threshold"` (context getting large) or `"overflow"` (context exceeded limit).
```json
{
- "type": "auto_compaction_end",
- "result": {
- "summary": "Summary of conversation...",
- "firstKeptEntryId": "abc123",
- "tokensBefore": 150000,
- "details": {}
- },
- "aborted": false,
- "willRetry": false
+ "type": "auto_compaction_end",
+ "result": {
+ "summary": "Summary of conversation...",
+ "firstKeptEntryId": "abc123",
+ "tokensBefore": 150000,
+ "details": {}
+ },
+ "aborted": false,
+ "willRetry": false
}
```
@@ -753,29 +793,30 @@ Emitted when automatic retry is triggered after a transient error (overloaded, r
```json
{
- "type": "auto_retry_start",
- "attempt": 1,
- "maxAttempts": 3,
- "delayMs": 2000,
- "errorMessage": "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
+ "type": "auto_retry_start",
+ "attempt": 1,
+ "maxAttempts": 3,
+ "delayMs": 2000,
+ "errorMessage": "529 {\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"Overloaded\"}}"
}
```
```json
{
- "type": "auto_retry_end",
- "success": true,
- "attempt": 2
+ "type": "auto_retry_end",
+ "success": true,
+ "attempt": 2
}
```
On final failure (max retries exceeded):
+
```json
{
- "type": "auto_retry_end",
- "success": false,
- "attempt": 3,
- "finalError": "529 overloaded_error: Overloaded"
+ "type": "auto_retry_end",
+ "success": false,
+ "attempt": 3,
+ "finalError": "529 overloaded_error: Overloaded"
}
```
@@ -785,10 +826,10 @@ Emitted when a hook throws an error.
```json
{
- "type": "hook_error",
- "hookPath": "/path/to/hook.ts",
- "event": "tool_call",
- "error": "Error message..."
+ "type": "hook_error",
+ "hookPath": "/path/to/hook.ts",
+ "event": "tool_call",
+ "error": "Error message..."
}
```
@@ -798,10 +839,10 @@ Failed commands return a response with `success: false`:
```json
{
- "type": "response",
- "command": "set_model",
- "success": false,
- "error": "Model not found: invalid/model"
+ "type": "response",
+ "command": "set_model",
+ "success": false,
+ "error": "Model not found: invalid/model"
}
```
@@ -809,16 +850,17 @@ Parse errors:
```json
{
- "type": "response",
- "command": "parse",
- "success": false,
- "error": "Failed to parse command: Unexpected token..."
+ "type": "response",
+ "command": "parse",
+ "success": false,
+ "error": "Failed to parse command: Unexpected token..."
}
```
## Types
Source files:
+
- [`packages/ai/src/types.ts`](../../ai/src/types.ts) - `Model`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`
- [`packages/agent/src/types.ts`](../../agent/src/types.ts) - `AgentMessage`, `AgentEvent`
- [`src/core/messages.ts`](../src/core/messages.ts) - `BashExecutionMessage`
@@ -828,21 +870,21 @@ Source files:
```json
{
- "id": "claude-sonnet-4-20250514",
- "name": "Claude Sonnet 4",
- "api": "anthropic-messages",
- "provider": "anthropic",
- "baseUrl": "https://api.anthropic.com",
- "reasoning": true,
- "input": ["text", "image"],
- "contextWindow": 200000,
- "maxTokens": 16384,
- "cost": {
- "input": 3.0,
- "output": 15.0,
- "cacheRead": 0.3,
- "cacheWrite": 3.75
- }
+ "id": "claude-sonnet-4-20250514",
+ "name": "Claude Sonnet 4",
+ "api": "anthropic-messages",
+ "provider": "anthropic",
+ "baseUrl": "https://api.anthropic.com",
+ "reasoning": true,
+ "input": ["text", "image"],
+ "contextWindow": 200000,
+ "maxTokens": 16384,
+ "cost": {
+ "input": 3.0,
+ "output": 15.0,
+ "cacheRead": 0.3,
+ "cacheWrite": 3.75
+ }
}
```
@@ -850,10 +892,10 @@ Source files:
```json
{
- "role": "user",
- "content": "Hello!",
- "timestamp": 1733234567890,
- "attachments": []
+ "role": "user",
+ "content": "Hello!",
+ "timestamp": 1733234567890,
+ "attachments": []
}
```
@@ -863,24 +905,24 @@ The `content` field can be a string or an array of `TextContent`/`ImageContent`
```json
{
- "role": "assistant",
- "content": [
- {"type": "text", "text": "Hello! How can I help?"},
- {"type": "thinking", "thinking": "User is greeting me..."},
- {"type": "toolCall", "id": "call_123", "name": "bash", "arguments": {"command": "ls"}}
- ],
- "api": "anthropic-messages",
- "provider": "anthropic",
- "model": "claude-sonnet-4-20250514",
- "usage": {
- "input": 100,
- "output": 50,
- "cacheRead": 0,
- "cacheWrite": 0,
- "cost": {"input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105}
- },
- "stopReason": "stop",
- "timestamp": 1733234567890
+ "role": "assistant",
+ "content": [
+ { "type": "text", "text": "Hello! How can I help?" },
+ { "type": "thinking", "thinking": "User is greeting me..." },
+ { "type": "toolCall", "id": "call_123", "name": "bash", "arguments": { "command": "ls" } }
+ ],
+ "api": "anthropic-messages",
+ "provider": "anthropic",
+ "model": "claude-sonnet-4-20250514",
+ "usage": {
+ "input": 100,
+ "output": 50,
+ "cacheRead": 0,
+ "cacheWrite": 0,
+ "cost": { "input": 0.0003, "output": 0.00075, "cacheRead": 0, "cacheWrite": 0, "total": 0.00105 }
+ },
+ "stopReason": "stop",
+ "timestamp": 1733234567890
}
```
@@ -890,12 +932,12 @@ Stop reasons: `"stop"`, `"length"`, `"toolUse"`, `"error"`, `"aborted"`
```json
{
- "role": "toolResult",
- "toolCallId": "call_123",
- "toolName": "bash",
- "content": [{"type": "text", "text": "total 48\ndrwxr-xr-x ..."}],
- "isError": false,
- "timestamp": 1733234567890
+ "role": "toolResult",
+ "toolCallId": "call_123",
+ "toolName": "bash",
+ "content": [{ "type": "text", "text": "total 48\ndrwxr-xr-x ..." }],
+ "isError": false,
+ "timestamp": 1733234567890
}
```
@@ -905,14 +947,14 @@ Created by the `bash` RPC command (not by LLM tool calls):
```json
{
- "role": "bashExecution",
- "command": "ls -la",
- "output": "total 48\ndrwxr-xr-x ...",
- "exitCode": 0,
- "cancelled": false,
- "truncated": false,
- "fullOutputPath": null,
- "timestamp": 1733234567890
+ "role": "bashExecution",
+ "command": "ls -la",
+ "output": "total 48\ndrwxr-xr-x ...",
+ "exitCode": 0,
+ "cancelled": false,
+ "truncated": false,
+ "fullOutputPath": null,
+ "timestamp": 1733234567890
}
```
@@ -920,14 +962,14 @@ Created by the `bash` RPC command (not by LLM tool calls):
```json
{
- "id": "img1",
- "type": "image",
- "fileName": "photo.jpg",
- "mimeType": "image/jpeg",
- "size": 102400,
- "content": "base64-encoded-data...",
- "extractedText": null,
- "preview": null
+ "id": "img1",
+ "type": "image",
+ "fileName": "photo.jpg",
+ "mimeType": "image/jpeg",
+ "size": 102400,
+ "content": "base64-encoded-data...",
+ "extractedText": null,
+ "preview": null
}
```
@@ -961,7 +1003,7 @@ for event in read_events():
delta = event.get("assistantMessageEvent", {})
if delta.get("type") == "text_delta":
print(delta["delta"], end="", flush=True)
-
+
if event.get("type") == "agent_end":
print()
break
@@ -978,14 +1020,14 @@ const readline = require("readline");
const agent = spawn("pi", ["--mode", "rpc", "--no-session"]);
readline.createInterface({ input: agent.stdout }).on("line", (line) => {
- const event = JSON.parse(line);
-
- if (event.type === "message_update") {
- const { assistantMessageEvent } = event;
- if (assistantMessageEvent.type === "text_delta") {
- process.stdout.write(assistantMessageEvent.delta);
- }
- }
+ const event = JSON.parse(line);
+
+ if (event.type === "message_update") {
+ const { assistantMessageEvent } = event;
+ if (assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(assistantMessageEvent.delta);
+ }
+ }
});
// Send prompt
@@ -993,6 +1035,6 @@ agent.stdin.write(JSON.stringify({ type: "prompt", message: "Hello" }) + "\n");
// Abort on Ctrl+C
process.on("SIGINT", () => {
- agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
+ agent.stdin.write(JSON.stringify({ type: "abort" }) + "\n");
});
```
diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md
index e77470db9..143b1dad2 100644
--- a/packages/coding-agent/docs/sdk.md
+++ b/packages/coding-agent/docs/sdk.md
@@ -5,6 +5,7 @@
The SDK provides programmatic access to pi's agent capabilities. Use it to embed pi in other applications, build custom interfaces, or integrate with automated workflows.
**Example use cases:**
+
- Build a custom UI (web, desktop, mobile)
- Integrate agent capabilities into existing applications
- Create automated pipelines with agent reasoning
@@ -16,22 +17,22 @@ See [examples/sdk/](../examples/sdk/) for working examples from minimal to full
## Quick Start
```typescript
-import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverAuthStorage, discoverModels, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Set up credential storage and model registry
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
- authStorage,
- modelRegistry,
+ sessionManager: SessionManager.inMemory(),
+ authStorage,
+ modelRegistry,
});
session.subscribe((event) => {
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
});
await session.prompt("What files are in the current directory?");
@@ -40,7 +41,7 @@ await session.prompt("What files are in the current directory?");
## Installation
```bash
-npm install @mariozechner/pi-coding-agent
+npm install @oh-my-pi/pi-coding-agent
```
The SDK is included in the main package. No separate installation needed.
@@ -52,21 +53,22 @@ The SDK is included in the main package. No separate installation needed.
The main factory function. Creates an `AgentSession` with configurable options.
**Philosophy:** "Omit to discover, provide to override."
+
- Omit an option → pi discovers/loads from standard locations
- Provide an option → your value is used, discovery skipped for that option
```typescript
-import { createAgentSession } from "@mariozechner/pi-coding-agent";
+import { createAgentSession } from "@oh-my-pi/pi-coding-agent";
// Minimal: all defaults (discovers everything from cwd and ~/.pi/agent)
const { session } = await createAgentSession();
// Custom: override specific options
const { session } = await createAgentSession({
- model: myModel,
- systemPrompt: "You are helpful.",
- tools: [readTool, bashTool],
- sessionManager: SessionManager.inMemory(),
+ model: myModel,
+ systemPrompt: "You are helpful.",
+ tools: [readTool, bashTool],
+ sessionManager: SessionManager.inMemory(),
});
```
@@ -76,55 +78,58 @@ The session manages the agent lifecycle, message history, and event streaming.
```typescript
interface AgentSession {
- // Send a prompt and wait for completion
- prompt(text: string, options?: PromptOptions): Promise;
-
- // Subscribe to events (returns unsubscribe function)
- subscribe(listener: (event: AgentSessionEvent) => void): () => void;
-
- // Session info
- sessionFile: string | undefined; // undefined for in-memory
- sessionId: string;
-
- // Model control
- setModel(model: Model): Promise;
- setThinkingLevel(level: ThinkingLevel): void;
- cycleModel(): Promise;
- cycleThinkingLevel(): ThinkingLevel | undefined;
-
- // State access
- agent: Agent;
- model: Model | undefined;
- thinkingLevel: ThinkingLevel;
- messages: AgentMessage[];
- isStreaming: boolean;
-
- // Session management
- newSession(options?: { parentSession?: string }): Promise; // Returns false if cancelled by hook
- switchSession(sessionPath: string): Promise;
-
- // Branching
- branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
- navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
-
- // Hook message injection
- sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise;
-
- // Compaction
- compact(customInstructions?: string): Promise;
- abortCompaction(): void;
-
- // Abort current operation
- abort(): Promise;
-
- // Cleanup
- dispose(): void;
+ // Send a prompt and wait for completion
+ prompt(text: string, options?: PromptOptions): Promise;
+
+ // Subscribe to events (returns unsubscribe function)
+ subscribe(listener: (event: AgentSessionEvent) => void): () => void;
+
+ // Session info
+ sessionFile: string | undefined; // undefined for in-memory
+ sessionId: string;
+
+ // Model control
+ setModel(model: Model): Promise;
+ setThinkingLevel(level: ThinkingLevel): void;
+ cycleModel(): Promise;
+ cycleThinkingLevel(): ThinkingLevel | undefined;
+
+ // State access
+ agent: Agent;
+ model: Model | undefined;
+ thinkingLevel: ThinkingLevel;
+ messages: AgentMessage[];
+ isStreaming: boolean;
+
+ // Session management
+ newSession(options?: { parentSession?: string }): Promise; // Returns false if cancelled by hook
+ switchSession(sessionPath: string): Promise;
+
+ // Branching
+ branch(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
+ navigateTree(
+ targetId: string,
+ options?: { summarize?: boolean }
+ ): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
+
+ // Hook message injection
+ sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise;
+
+ // Compaction
+ compact(customInstructions?: string): Promise;
+ abortCompaction(): void;
+
+ // Abort current operation
+ abort(): Promise;
+
+ // Cleanup
+ dispose(): void;
}
```
### Agent and AgentState
-The `Agent` class (from `@mariozechner/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`.
+The `Agent` class (from `@oh-my-pi/pi-agent-core`) handles the core LLM interaction. Access it via `session.agent`.
```typescript
// Access current state
@@ -149,59 +154,59 @@ Subscribe to events to receive streaming output and lifecycle notifications.
```typescript
session.subscribe((event) => {
- switch (event.type) {
- // Streaming text from assistant
- case "message_update":
- if (event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
- if (event.assistantMessageEvent.type === "thinking_delta") {
- // Thinking output (if thinking enabled)
- }
- break;
-
- // Tool execution
- case "tool_execution_start":
- console.log(`Tool: ${event.toolName}`);
- break;
- case "tool_execution_update":
- // Streaming tool output
- break;
- case "tool_execution_end":
- console.log(`Result: ${event.isError ? "error" : "success"}`);
- break;
-
- // Message lifecycle
- case "message_start":
- // New message starting
- break;
- case "message_end":
- // Message complete
- break;
-
- // Agent lifecycle
- case "agent_start":
- // Agent started processing prompt
- break;
- case "agent_end":
- // Agent finished (event.messages contains new messages)
- break;
-
- // Turn lifecycle (one LLM response + tool calls)
- case "turn_start":
- break;
- case "turn_end":
- // event.message: assistant response
- // event.toolResults: tool results from this turn
- break;
-
- // Session events (auto-compaction, retry)
- case "auto_compaction_start":
- case "auto_compaction_end":
- case "auto_retry_start":
- case "auto_retry_end":
- break;
- }
+ switch (event.type) {
+ // Streaming text from assistant
+ case "message_update":
+ if (event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
+ if (event.assistantMessageEvent.type === "thinking_delta") {
+ // Thinking output (if thinking enabled)
+ }
+ break;
+
+ // Tool execution
+ case "tool_execution_start":
+ console.log(`Tool: ${event.toolName}`);
+ break;
+ case "tool_execution_update":
+ // Streaming tool output
+ break;
+ case "tool_execution_end":
+ console.log(`Result: ${event.isError ? "error" : "success"}`);
+ break;
+
+ // Message lifecycle
+ case "message_start":
+ // New message starting
+ break;
+ case "message_end":
+ // Message complete
+ break;
+
+ // Agent lifecycle
+ case "agent_start":
+ // Agent started processing prompt
+ break;
+ case "agent_end":
+ // Agent finished (event.messages contains new messages)
+ break;
+
+ // Turn lifecycle (one LLM response + tool calls)
+ case "turn_start":
+ break;
+ case "turn_end":
+ // event.message: assistant response
+ // event.toolResults: tool results from this turn
+ break;
+
+ // Session events (auto-compaction, retry)
+ case "auto_compaction_start":
+ case "auto_compaction_end":
+ case "auto_retry_start":
+ case "auto_retry_end":
+ break;
+ }
});
```
@@ -211,15 +216,16 @@ session.subscribe((event) => {
```typescript
const { session } = await createAgentSession({
- // Working directory for project-local discovery
- cwd: process.cwd(), // default
-
- // Global config directory
- agentDir: "~/.pi/agent", // default (expands ~)
+ // Working directory for project-local discovery
+ cwd: process.cwd(), // default
+
+ // Global config directory
+ agentDir: "~/.pi/agent", // default (expands ~)
});
```
`cwd` is used for:
+
- Project hooks (`.pi/hooks/`)
- Project tools (`.pi/tools/`)
- Project skills (`.pi/skills/`)
@@ -228,6 +234,7 @@ const { session } = await createAgentSession({
- Session directory naming
`agentDir` is used for:
+
- Global hooks (`hooks/`)
- Global tools (`tools/`)
- Global skills (`skills/`)
@@ -241,8 +248,8 @@ const { session } = await createAgentSession({
### Model
```typescript
-import { getModel } from "@mariozechner/pi-ai";
-import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
+import { getModel } from "@oh-my-pi/pi-ai";
+import { discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
@@ -259,21 +266,22 @@ const customModel = modelRegistry.find("my-provider", "my-model");
const available = await modelRegistry.getAvailable();
const { session } = await createAgentSession({
- model: opus,
- thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh
-
- // Models for cycling (Ctrl+P in interactive mode)
- scopedModels: [
- { model: opus, thinkingLevel: "high" },
- { model: haiku, thinkingLevel: "off" },
- ],
-
- authStorage,
- modelRegistry,
+ model: opus,
+ thinkingLevel: "medium", // off, minimal, low, medium, high, xhigh
+
+ // Models for cycling (Ctrl+P in interactive mode)
+ scopedModels: [
+ { model: opus, thinkingLevel: "high" },
+ { model: haiku, thinkingLevel: "off" },
+ ],
+
+ authStorage,
+ modelRegistry,
});
```
If no model is provided:
+
1. Tries to restore from session (if continuing)
2. Uses default from settings
3. Falls back to first available model
@@ -283,22 +291,23 @@ If no model is provided:
### API Keys and OAuth
API key resolution priority (handled by AuthStorage):
+
1. Runtime overrides (via `setRuntimeApiKey`, not persisted)
2. Stored credentials in `auth.json` (API keys or OAuth tokens)
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.json`)
```typescript
-import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
+import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
// Default: uses ~/.pi/agent/auth.json and ~/.pi/agent/models.json
const authStorage = discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
- authStorage,
- modelRegistry,
+ sessionManager: SessionManager.inMemory(),
+ authStorage,
+ modelRegistry,
});
// Runtime API key override (not persisted to disk)
@@ -309,9 +318,9 @@ const customAuth = new AuthStorage("/my/app/auth.json");
const customRegistry = new ModelRegistry(customAuth, "/my/app/models.json");
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
- authStorage: customAuth,
- modelRegistry: customRegistry,
+ sessionManager: SessionManager.inMemory(),
+ authStorage: customAuth,
+ modelRegistry: customRegistry,
});
// No custom models.json (built-in models only)
@@ -324,13 +333,13 @@ const simpleRegistry = new ModelRegistry(authStorage);
```typescript
const { session } = await createAgentSession({
- // Replace entirely
- systemPrompt: "You are a helpful assistant.",
-
- // Or modify default (receives default, returns modified)
- systemPrompt: (defaultPrompt) => {
- return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
- },
+ // Replace entirely
+ systemPrompt: "You are a helpful assistant.",
+
+ // Or modify default (receives default, returns modified)
+ systemPrompt: (defaultPrompt) => {
+ return `${defaultPrompt}\n\n## Additional Rules\n- Be concise`;
+ },
});
```
@@ -340,20 +349,25 @@ const { session } = await createAgentSession({
```typescript
import {
- codingTools, // read, bash, edit, write (default)
- readOnlyTools, // read, grep, find, ls
- readTool, bashTool, editTool, writeTool,
- grepTool, findTool, lsTool,
-} from "@mariozechner/pi-coding-agent";
+ codingTools, // read, bash, edit, write (default)
+ readOnlyTools, // read, grep, find, ls
+ readTool,
+ bashTool,
+ editTool,
+ writeTool,
+ grepTool,
+ findTool,
+ lsTool,
+} from "@oh-my-pi/pi-coding-agent";
// Use built-in tool set
const { session } = await createAgentSession({
- tools: readOnlyTools,
+ tools: readOnlyTools,
});
// Pick specific tools
const { session } = await createAgentSession({
- tools: [readTool, bashTool, grepTool],
+ tools: [readTool, bashTool, grepTool],
});
```
@@ -363,37 +377,39 @@ const { session } = await createAgentSession({
```typescript
import {
- createCodingTools, // Creates [read, bash, edit, write] for specific cwd
- createReadOnlyTools, // Creates [read, grep, find, ls] for specific cwd
- createReadTool,
- createBashTool,
- createEditTool,
- createWriteTool,
- createGrepTool,
- createFindTool,
- createLsTool,
-} from "@mariozechner/pi-coding-agent";
+ createCodingTools, // Creates [read, bash, edit, write] for specific cwd
+ createReadOnlyTools, // Creates [read, grep, find, ls] for specific cwd
+ createReadTool,
+ createBashTool,
+ createEditTool,
+ createWriteTool,
+ createGrepTool,
+ createFindTool,
+ createLsTool,
+} from "@oh-my-pi/pi-coding-agent";
const cwd = "/path/to/project";
// Use factory for tool sets
const { session } = await createAgentSession({
- cwd,
- tools: createCodingTools(cwd), // Tools resolve paths relative to cwd
+ cwd,
+ tools: createCodingTools(cwd), // Tools resolve paths relative to cwd
});
// Or pick specific tools
const { session } = await createAgentSession({
- cwd,
- tools: [createReadTool(cwd), createBashTool(cwd), createGrepTool(cwd)],
+ cwd,
+ tools: [createReadTool(cwd), createBashTool(cwd), createGrepTool(cwd)],
});
```
**When you don't need factories:**
+
- If you omit `tools`, pi automatically creates them with the correct `cwd`
- If you use `process.cwd()` as your `cwd`, the pre-built instances work fine
**When you must use factories:**
+
- When you specify both `cwd` (different from `process.cwd()`) AND `tools`
> See [examples/sdk/05-tools.ts](../examples/sdk/05-tools.ts)
@@ -402,36 +418,36 @@ const { session } = await createAgentSession({
```typescript
import { Type } from "@sinclair/typebox";
-import { createAgentSession, discoverCustomTools, type CustomTool } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverCustomTools, type CustomTool } from "@oh-my-pi/pi-coding-agent";
// Inline custom tool
const myTool: CustomTool = {
- name: "my_tool",
- label: "My Tool",
- description: "Does something useful",
- parameters: Type.Object({
- input: Type.String({ description: "Input value" }),
- }),
- execute: async (toolCallId, params) => ({
- content: [{ type: "text", text: `Result: ${params.input}` }],
- details: {},
- }),
+ name: "my_tool",
+ label: "My Tool",
+ description: "Does something useful",
+ parameters: Type.Object({
+ input: Type.String({ description: "Input value" }),
+ }),
+ execute: async (toolCallId, params) => ({
+ content: [{ type: "text", text: `Result: ${params.input}` }],
+ details: {},
+ }),
};
// Replace discovery with inline tools
const { session } = await createAgentSession({
- customTools: [{ tool: myTool }],
+ customTools: [{ tool: myTool }],
});
// Merge with discovered tools
const discovered = await discoverCustomTools();
const { session } = await createAgentSession({
- customTools: [...discovered, { tool: myTool }],
+ customTools: [...discovered, { tool: myTool }],
});
// Add paths without replacing discovery
const { session } = await createAgentSession({
- additionalCustomToolPaths: ["/extra/tools"],
+ additionalCustomToolPaths: ["/extra/tools"],
});
```
@@ -440,67 +456,71 @@ const { session } = await createAgentSession({
### Hooks
```typescript
-import { createAgentSession, discoverHooks, type HookFactory } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverHooks, type HookFactory } from "@oh-my-pi/pi-coding-agent";
// Inline hook
const loggingHook: HookFactory = (api) => {
- // Log tool calls
- api.on("tool_call", async (event) => {
- console.log(`Tool: ${event.toolName}`);
- return undefined; // Don't block
- });
-
- // Block dangerous commands
- api.on("tool_call", async (event) => {
- if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
- return { block: true, reason: "Dangerous command" };
- }
- return undefined;
- });
-
- // Register custom slash command
- api.registerCommand("stats", {
- description: "Show session stats",
- handler: async (ctx) => {
- const entries = ctx.sessionManager.getEntries();
- ctx.ui.notify(`${entries.length} entries`, "info");
- },
- });
-
- // Inject messages
- api.sendMessage({
- customType: "my-hook",
- content: "Hook initialized",
- display: false, // Hidden from TUI
- }, false); // Don't trigger agent turn
-
- // Persist hook state
- api.appendEntry("my-hook", { initialized: true });
+ // Log tool calls
+ api.on("tool_call", async (event) => {
+ console.log(`Tool: ${event.toolName}`);
+ return undefined; // Don't block
+ });
+
+ // Block dangerous commands
+ api.on("tool_call", async (event) => {
+ if (event.toolName === "bash" && event.input.command?.includes("rm -rf")) {
+ return { block: true, reason: "Dangerous command" };
+ }
+ return undefined;
+ });
+
+ // Register custom slash command
+ api.registerCommand("stats", {
+ description: "Show session stats",
+ handler: async (ctx) => {
+ const entries = ctx.sessionManager.getEntries();
+ ctx.ui.notify(`${entries.length} entries`, "info");
+ },
+ });
+
+ // Inject messages
+ api.sendMessage(
+ {
+ customType: "my-hook",
+ content: "Hook initialized",
+ display: false, // Hidden from TUI
+ },
+ false
+ ); // Don't trigger agent turn
+
+ // Persist hook state
+ api.appendEntry("my-hook", { initialized: true });
};
// Replace discovery
const { session } = await createAgentSession({
- hooks: [{ factory: loggingHook }],
+ hooks: [{ factory: loggingHook }],
});
// Disable all hooks
const { session } = await createAgentSession({
- hooks: [],
+ hooks: [],
});
// Merge with discovered
const discovered = await discoverHooks();
const { session } = await createAgentSession({
- hooks: [...discovered, { factory: loggingHook }],
+ hooks: [...discovered, { factory: loggingHook }],
});
// Add paths without replacing
const { session } = await createAgentSession({
- additionalHookPaths: ["/extra/hooks"],
+ additionalHookPaths: ["/extra/hooks"],
});
```
Hook API methods:
+
- `api.on(event, handler)` - Subscribe to events
- `api.sendMessage(message, triggerTurn?)` - Inject message (creates `CustomMessageEntry`)
- `api.appendEntry(customType, data?)` - Persist hook state (not in LLM context)
@@ -513,34 +533,34 @@ Hook API methods:
### Skills
```typescript
-import { createAgentSession, discoverSkills, type Skill } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverSkills, type Skill } from "@oh-my-pi/pi-coding-agent";
// Discover and filter
const allSkills = discoverSkills();
-const filtered = allSkills.filter(s => s.name.includes("search"));
+const filtered = allSkills.filter((s) => s.name.includes("search"));
// Custom skill
const mySkill: Skill = {
- name: "my-skill",
- description: "Custom instructions",
- filePath: "/path/to/SKILL.md",
- baseDir: "/path/to",
- source: "custom",
+ name: "my-skill",
+ description: "Custom instructions",
+ filePath: "/path/to/SKILL.md",
+ baseDir: "/path/to",
+ source: "custom",
};
const { session } = await createAgentSession({
- skills: [...filtered, mySkill],
+ skills: [...filtered, mySkill],
});
// Disable skills
const { session } = await createAgentSession({
- skills: [],
+ skills: [],
});
// Discovery with settings filter
const skills = discoverSkills(process.cwd(), undefined, {
- ignoredSkills: ["browser-*"], // glob patterns to exclude
- includeSkills: ["search-*"], // glob patterns to include (empty = all)
+ ignoredSkills: ["browser-*"], // glob patterns to exclude
+ includeSkills: ["search-*"], // glob patterns to include (empty = all)
});
```
@@ -549,25 +569,25 @@ const skills = discoverSkills(process.cwd(), undefined, {
### Context Files
```typescript
-import { createAgentSession, discoverContextFiles } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverContextFiles } from "@oh-my-pi/pi-coding-agent";
// Discover AGENTS.md files
const discovered = discoverContextFiles();
// Add custom context
const { session } = await createAgentSession({
- contextFiles: [
- ...discovered,
- {
- path: "/virtual/AGENTS.md",
- content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
- },
- ],
+ contextFiles: [
+ ...discovered,
+ {
+ path: "/virtual/AGENTS.md",
+ content: "# Guidelines\n\n- Be concise\n- Use TypeScript",
+ },
+ ],
});
// Disable context files
const { session } = await createAgentSession({
- contextFiles: [],
+ contextFiles: [],
});
```
@@ -576,19 +596,19 @@ const { session } = await createAgentSession({
### Slash Commands
```typescript
-import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverSlashCommands, type FileSlashCommand } from "@oh-my-pi/pi-coding-agent";
const discovered = discoverSlashCommands();
const customCommand: FileSlashCommand = {
- name: "deploy",
- description: "Deploy the application",
- source: "(custom)",
- content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
+ name: "deploy",
+ description: "Deploy the application",
+ source: "(custom)",
+ content: "# Deploy\n\n1. Build\n2. Test\n3. Deploy",
};
const { session } = await createAgentSession({
- slashCommands: [...discovered, customCommand],
+ slashCommands: [...discovered, customCommand],
});
```
@@ -599,41 +619,41 @@ const { session } = await createAgentSession({
Sessions use a tree structure with `id`/`parentId` linking, enabling in-place branching.
```typescript
-import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
// In-memory (no persistence)
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
+ sessionManager: SessionManager.inMemory(),
});
// New persistent session
const { session } = await createAgentSession({
- sessionManager: SessionManager.create(process.cwd()),
+ sessionManager: SessionManager.create(process.cwd()),
});
// Continue most recent
const { session, modelFallbackMessage } = await createAgentSession({
- sessionManager: SessionManager.continueRecent(process.cwd()),
+ sessionManager: SessionManager.continueRecent(process.cwd()),
});
if (modelFallbackMessage) {
- console.log("Note:", modelFallbackMessage);
+ console.log("Note:", modelFallbackMessage);
}
// Open specific file
const { session } = await createAgentSession({
- sessionManager: SessionManager.open("/path/to/session.jsonl"),
+ sessionManager: SessionManager.open("/path/to/session.jsonl"),
});
// List available sessions
const sessions = SessionManager.list(process.cwd());
for (const info of sessions) {
- console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`);
+ console.log(`${info.id}: ${info.firstMessage} (${info.messageCount} messages)`);
}
// Custom session directory (no cwd encoding)
const customDir = "/path/to/my-sessions";
const { session } = await createAgentSession({
- sessionManager: SessionManager.create(process.cwd(), customDir),
+ sessionManager: SessionManager.create(process.cwd(), customDir),
});
```
@@ -643,21 +663,21 @@ const { session } = await createAgentSession({
const sm = SessionManager.open("/path/to/session.jsonl");
// Tree traversal
-const entries = sm.getEntries(); // All entries (excludes header)
-const tree = sm.getTree(); // Full tree structure
-const path = sm.getPath(); // Path from root to current leaf
-const leaf = sm.getLeafEntry(); // Current leaf entry
-const entry = sm.getEntry(id); // Get entry by ID
-const children = sm.getChildren(id); // Direct children of entry
+const entries = sm.getEntries(); // All entries (excludes header)
+const tree = sm.getTree(); // Full tree structure
+const path = sm.getPath(); // Path from root to current leaf
+const leaf = sm.getLeafEntry(); // Current leaf entry
+const entry = sm.getEntry(id); // Get entry by ID
+const children = sm.getChildren(id); // Direct children of entry
// Labels
-const label = sm.getLabel(id); // Get label for entry
+const label = sm.getLabel(id); // Get label for entry
sm.appendLabelChange(id, "checkpoint"); // Set label
// Branching
-sm.branch(entryId); // Move leaf to earlier entry
-sm.branchWithSummary(id, "Summary..."); // Branch with context summary
-sm.createBranchedSession(leafId); // Extract path to new file
+sm.branch(entryId); // Move leaf to earlier entry
+sm.branchWithSummary(id, "Summary..."); // Branch with context summary
+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)
@@ -665,40 +685,42 @@ sm.createBranchedSession(leafId); // Extract path to new file
### Settings Management
```typescript
-import { createAgentSession, SettingsManager, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, SettingsManager, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Default: loads from files (global + project merged)
const { session } = await createAgentSession({
- settingsManager: SettingsManager.create(),
+ settingsManager: SettingsManager.create(),
});
// With overrides
const settingsManager = SettingsManager.create();
settingsManager.applyOverrides({
- compaction: { enabled: false },
- retry: { enabled: true, maxRetries: 5 },
+ compaction: { enabled: false },
+ retry: { enabled: true, maxRetries: 5 },
});
const { session } = await createAgentSession({ settingsManager });
// In-memory (no file I/O, for testing)
const { session } = await createAgentSession({
- settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
- sessionManager: SessionManager.inMemory(),
+ settingsManager: SettingsManager.inMemory({ compaction: { enabled: false } }),
+ sessionManager: SessionManager.inMemory(),
});
// Custom directories
const { session } = await createAgentSession({
- settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
+ settingsManager: SettingsManager.create("/custom/cwd", "/custom/agent"),
});
```
**Static factories:**
+
- `SettingsManager.create(cwd?, agentDir?)` - Load from files
- `SettingsManager.inMemory(settings?)` - No file I/O
**Project-specific settings:**
Settings load from two locations and merge:
+
1. Global: `~/.pi/agent/settings.json`
2. Project: `/.pi/settings.json`
@@ -711,27 +733,27 @@ Project overrides global. Nested objects merge keys. Setters only modify global
All discovery functions accept optional `cwd` and `agentDir` parameters.
```typescript
-import { getModel } from "@mariozechner/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import {
- AuthStorage,
- ModelRegistry,
- discoverAuthStorage,
- discoverModels,
- discoverSkills,
- discoverHooks,
- discoverCustomTools,
- discoverContextFiles,
- discoverSlashCommands,
- loadSettings,
- buildSystemPrompt,
-} from "@mariozechner/pi-coding-agent";
+ AuthStorage,
+ ModelRegistry,
+ discoverAuthStorage,
+ discoverModels,
+ discoverSkills,
+ discoverHooks,
+ discoverCustomTools,
+ discoverContextFiles,
+ discoverSlashCommands,
+ loadSettings,
+ buildSystemPrompt,
+} from "@oh-my-pi/pi-coding-agent";
// Auth and Models
-const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
-const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
-const allModels = modelRegistry.getAll(); // All models (built-in + custom)
+const authStorage = discoverAuthStorage(); // ~/.pi/agent/auth.json
+const modelRegistry = discoverModels(authStorage); // + ~/.pi/agent/models.json
+const allModels = modelRegistry.getAll(); // All models (built-in + custom)
const available = await modelRegistry.getAvailable(); // Only models with API keys
-const model = modelRegistry.find("provider", "id"); // Find specific model
+const model = modelRegistry.find("provider", "id"); // Find specific model
const builtIn = getModel("anthropic", "claude-opus-4-5"); // Built-in only
// Skills
@@ -754,10 +776,10 @@ const settings = loadSettings(cwd, agentDir);
// Build system prompt manually
const prompt = buildSystemPrompt({
- skills,
- contextFiles,
- appendPrompt: "Additional instructions",
- cwd,
+ skills,
+ contextFiles,
+ appendPrompt: "Additional instructions",
+ cwd,
});
```
@@ -767,43 +789,43 @@ const prompt = buildSystemPrompt({
```typescript
interface CreateAgentSessionResult {
- // The session
- session: AgentSession;
-
- // Custom tools (for UI setup)
- customToolsResult: {
- tools: LoadedCustomTool[];
- setUIContext: (ctx, hasUI) => void;
- };
-
- // Warning if session model couldn't be restored
- modelFallbackMessage?: string;
+ // The session
+ session: AgentSession;
+
+ // Custom tools (for UI setup)
+ customToolsResult: {
+ tools: LoadedCustomTool[];
+ setUIContext: (ctx, hasUI) => void;
+ };
+
+ // Warning if session model couldn't be restored
+ modelFallbackMessage?: string;
}
```
## Complete Example
```typescript
-import { getModel } from "@mariozechner/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import {
- AuthStorage,
- createAgentSession,
- ModelRegistry,
- SessionManager,
- SettingsManager,
- readTool,
- bashTool,
- type HookFactory,
- type CustomTool,
-} from "@mariozechner/pi-coding-agent";
+ AuthStorage,
+ createAgentSession,
+ ModelRegistry,
+ SessionManager,
+ SettingsManager,
+ readTool,
+ bashTool,
+ type HookFactory,
+ type CustomTool,
+} from "@oh-my-pi/pi-coding-agent";
// Set up auth storage (custom location)
const authStorage = new AuthStorage("/custom/agent/auth.json");
// Runtime API key override (not persisted)
if (process.env.MY_KEY) {
- authStorage.setRuntimeApiKey("anthropic", process.env.MY_KEY);
+ authStorage.setRuntimeApiKey("anthropic", process.env.MY_KEY);
}
// Model registry (no custom models.json)
@@ -811,22 +833,22 @@ const modelRegistry = new ModelRegistry(authStorage);
// Inline hook
const auditHook: HookFactory = (api) => {
- api.on("tool_call", async (event) => {
- console.log(`[Audit] ${event.toolName}`);
- return undefined;
- });
+ api.on("tool_call", async (event) => {
+ console.log(`[Audit] ${event.toolName}`);
+ return undefined;
+ });
};
// Inline tool
const statusTool: CustomTool = {
- name: "status",
- label: "Status",
- description: "Get system status",
- parameters: Type.Object({}),
- execute: async () => ({
- content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
- details: {},
- }),
+ name: "status",
+ label: "Status",
+ description: "Get system status",
+ parameters: Type.Object({}),
+ execute: async () => ({
+ content: [{ type: "text", text: `Uptime: ${process.uptime()}s` }],
+ details: {},
+ }),
};
const model = getModel("anthropic", "claude-opus-4-5");
@@ -834,36 +856,36 @@ if (!model) throw new Error("Model not found");
// In-memory settings with overrides
const settingsManager = SettingsManager.inMemory({
- compaction: { enabled: false },
- retry: { enabled: true, maxRetries: 2 },
+ compaction: { enabled: false },
+ retry: { enabled: true, maxRetries: 2 },
});
const { session } = await createAgentSession({
- cwd: process.cwd(),
- agentDir: "/custom/agent",
-
- model,
- thinkingLevel: "off",
- authStorage,
- modelRegistry,
-
- systemPrompt: "You are a minimal assistant. Be concise.",
-
- tools: [readTool, bashTool],
- customTools: [{ tool: statusTool }],
- hooks: [{ factory: auditHook }],
- skills: [],
- contextFiles: [],
- slashCommands: [],
-
- sessionManager: SessionManager.inMemory(),
- settingsManager,
+ cwd: process.cwd(),
+ agentDir: "/custom/agent",
+
+ model,
+ thinkingLevel: "off",
+ authStorage,
+ modelRegistry,
+
+ systemPrompt: "You are a minimal assistant. Be concise.",
+
+ tools: [readTool, bashTool],
+ customTools: [{ tool: statusTool }],
+ hooks: [{ factory: auditHook }],
+ skills: [],
+ contextFiles: [],
+ slashCommands: [],
+
+ sessionManager: SessionManager.inMemory(),
+ settingsManager,
});
session.subscribe((event) => {
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
});
await session.prompt("Get status and list files.");
@@ -880,12 +902,14 @@ pi --mode rpc --no-session
See [RPC documentation](rpc.md) for the JSON protocol.
The SDK is preferred when:
+
- You want type safety
- You're in the same Node.js process
- You need direct access to agent state
- You want to customize tools/hooks programmatically
RPC mode is preferred when:
+
- You're integrating from another language
- You want process isolation
- You're building a language-agnostic client
@@ -947,24 +971,24 @@ For hook types, import from the hooks subpath:
```typescript
import type {
- HookAPI,
- HookMessage,
- HookFactory,
- HookEventContext,
- HookCommandContext,
- ToolCallEvent,
- ToolResultEvent,
-} from "@mariozechner/pi-coding-agent/hooks";
+ HookAPI,
+ HookMessage,
+ HookFactory,
+ HookEventContext,
+ HookCommandContext,
+ ToolCallEvent,
+ ToolResultEvent,
+} from "@oh-my-pi/pi-coding-agent/hooks";
```
For message utilities:
```typescript
-import { isHookMessage, createHookMessage } from "@mariozechner/pi-coding-agent";
+import { isHookMessage, createHookMessage } from "@oh-my-pi/pi-coding-agent";
```
For config utilities:
```typescript
-import { getAgentDir } from "@mariozechner/pi-coding-agent/config";
+import { getAgentDir } from "@oh-my-pi/pi-coding-agent/config";
```
diff --git a/packages/coding-agent/docs/theme.md b/packages/coding-agent/docs/theme.md
index 3eaffc83f..d6f559106 100644
--- a/packages/coding-agent/docs/theme.md
+++ b/packages/coding-agent/docs/theme.md
@@ -10,56 +10,56 @@ Every theme must define all color tokens. There are no optional colors.
### Core UI (10 colors)
-| Token | Purpose | Examples |
-|-------|---------|----------|
-| `accent` | Primary accent color | Logo, selected items, cursor (›) |
-| `border` | Normal borders | Selector borders, horizontal lines |
-| `borderAccent` | Highlighted borders | Changelog borders, special panels |
-| `borderMuted` | Subtle borders | Editor borders, secondary separators |
-| `success` | Success states | Success messages, diff additions |
-| `error` | Error states | Error messages, diff deletions |
-| `warning` | Warning states | Warning messages |
-| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
-| `dim` | Very dimmed text | Less important info, placeholders |
-| `text` | Default text color | Main content (usually `""`) |
-| `thinkingText` | Thinking block text | Assistant reasoning traces |
+| Token | Purpose | Examples |
+| -------------- | --------------------- | ------------------------------------ |
+| `accent` | Primary accent color | Logo, selected items, cursor (›) |
+| `border` | Normal borders | Selector borders, horizontal lines |
+| `borderAccent` | Highlighted borders | Changelog borders, special panels |
+| `borderMuted` | Subtle borders | Editor borders, secondary separators |
+| `success` | Success states | Success messages, diff additions |
+| `error` | Error states | Error messages, diff deletions |
+| `warning` | Warning states | Warning messages |
+| `muted` | Secondary/dimmed text | Metadata, descriptions, output |
+| `dim` | Very dimmed text | Less important info, placeholders |
+| `text` | Default text color | Main content (usually `""`) |
+| `thinkingText` | Thinking block text | Assistant reasoning traces |
### Backgrounds & Content Text (11 colors)
-| Token | Purpose |
-|-------|---------|
-| `selectedBg` | Selected/active line background (e.g., tree selector) |
-| `userMessageBg` | User message background |
-| `userMessageText` | User message text color |
-| `customMessageBg` | Hook custom message background |
-| `customMessageText` | Hook custom message text color |
-| `customMessageLabel` | Hook custom message label/type text |
-| `toolPendingBg` | Tool execution box (pending state) |
-| `toolSuccessBg` | Tool execution box (success state) |
-| `toolErrorBg` | Tool execution box (error state) |
-| `toolTitle` | Tool execution title/heading (e.g., `$ command`, `read file.txt`) |
-| `toolOutput` | Tool execution output text |
+| Token | Purpose |
+| -------------------- | ----------------------------------------------------------------- |
+| `selectedBg` | Selected/active line background (e.g., tree selector) |
+| `userMessageBg` | User message background |
+| `userMessageText` | User message text color |
+| `customMessageBg` | Hook custom message background |
+| `customMessageText` | Hook custom message text color |
+| `customMessageLabel` | Hook custom message label/type text |
+| `toolPendingBg` | Tool execution box (pending state) |
+| `toolSuccessBg` | Tool execution box (success state) |
+| `toolErrorBg` | Tool execution box (error state) |
+| `toolTitle` | Tool execution title/heading (e.g., `$ command`, `read file.txt`) |
+| `toolOutput` | Tool execution output text |
### Markdown (10 colors)
-| Token | Purpose |
-|-------|---------|
-| `mdHeading` | Heading text (`#`, `##`, etc) |
-| `mdLink` | Link text |
-| `mdLinkUrl` | Link URL (in parentheses) |
-| `mdCode` | Inline code (backticks) |
-| `mdCodeBlock` | Code block content |
-| `mdCodeBlockBorder` | Code block fences (```) |
-| `mdQuote` | Blockquote text |
-| `mdQuoteBorder` | Blockquote border (`│`) |
-| `mdHr` | Horizontal rule (`---`) |
-| `mdListBullet` | List bullets/numbers |
+| Token | Purpose |
+| ------------------- | ----------------------------- |
+| `mdHeading` | Heading text (`#`, `##`, etc) |
+| `mdLink` | Link text |
+| `mdLinkUrl` | Link URL (in parentheses) |
+| `mdCode` | Inline code (backticks) |
+| `mdCodeBlock` | Code block content |
+| `mdCodeBlockBorder` | Code block fences (```) |
+| `mdQuote` | Blockquote text |
+| `mdQuoteBorder` | Blockquote border (`│`) |
+| `mdHr` | Horizontal rule (`---`) |
+| `mdListBullet` | List bullets/numbers |
### Tool Diffs (3 colors)
-| Token | Purpose |
-|-------|---------|
-| `toolDiffAdded` | Added lines in tool diffs |
+| Token | Purpose |
+| ----------------- | --------------------------- |
+| `toolDiffAdded` | Added lines in tool diffs |
| `toolDiffRemoved` | Removed lines in tool diffs |
| `toolDiffContext` | Context lines in tool diffs |
@@ -69,37 +69,37 @@ Note: Diff colors are specific to tool execution boxes and must work with tool b
Future-proofing for syntax highlighting support:
-| Token | Purpose |
-|-------|---------|
-| `syntaxComment` | Comments |
-| `syntaxKeyword` | Keywords (`if`, `function`, etc) |
-| `syntaxFunction` | Function names |
-| `syntaxVariable` | Variable names |
-| `syntaxString` | String literals |
-| `syntaxNumber` | Number literals |
-| `syntaxType` | Type names |
-| `syntaxOperator` | Operators (`+`, `-`, etc) |
-| `syntaxPunctuation` | Punctuation (`;`, `,`, etc) |
+| Token | Purpose |
+| ------------------- | -------------------------------- |
+| `syntaxComment` | Comments |
+| `syntaxKeyword` | Keywords (`if`, `function`, etc) |
+| `syntaxFunction` | Function names |
+| `syntaxVariable` | Variable names |
+| `syntaxString` | String literals |
+| `syntaxNumber` | Number literals |
+| `syntaxType` | Type names |
+| `syntaxOperator` | Operators (`+`, `-`, etc) |
+| `syntaxPunctuation` | Punctuation (`;`, `,`, etc) |
### Thinking Level Borders (6 colors)
Editor border colors that indicate the current thinking/reasoning level:
-| Token | Purpose |
-|-------|---------|
-| `thinkingOff` | Border when thinking is off (most subtle) |
-| `thinkingMinimal` | Border for minimal thinking |
-| `thinkingLow` | Border for low thinking |
-| `thinkingMedium` | Border for medium thinking |
-| `thinkingHigh` | Border for high thinking |
-| `thinkingXhigh` | Border for xhigh thinking (most prominent, OpenAI codex-max only) |
+| Token | Purpose |
+| ----------------- | ----------------------------------------------------------------- |
+| `thinkingOff` | Border when thinking is off (most subtle) |
+| `thinkingMinimal` | Border for minimal thinking |
+| `thinkingLow` | Border for low thinking |
+| `thinkingMedium` | Border for medium thinking |
+| `thinkingHigh` | Border for high thinking |
+| `thinkingXhigh` | Border for xhigh thinking (most prominent, OpenAI codex-max only) |
These create a visual hierarchy: off → minimal → low → medium → high → xhigh
### Bash Mode (1 color)
-| Token | Purpose |
-|-------|---------|
+| Token | Purpose |
+| ---------- | ------------------------------------------------ |
| `bashMode` | Editor border color when in bash mode (! prefix) |
**Total: 50 color tokens** (all required)
@@ -108,20 +108,21 @@ These create a visual hierarchy: off → minimal → low → medium → high →
The `export` section is optional and controls colors used when exporting sessions to HTML via `/export`. If not specified, these colors are automatically derived from `userMessageBg` based on luminance detection.
-| Token | Purpose |
-|-------|---------|
-| `pageBg` | Page background color |
-| `cardBg` | Card/container background (headers, stats boxes) |
+| Token | Purpose |
+| -------- | ------------------------------------------------------------- |
+| `pageBg` | Page background color |
+| `cardBg` | Card/container background (headers, stats boxes) |
| `infoBg` | Info sections background (system prompt, notices, compaction) |
Example:
+
```json
{
- "export": {
- "pageBg": "#18181e",
- "cardBg": "#1e1e24",
- "infoBg": "#3c3728"
- }
+ "export": {
+ "pageBg": "#18181e",
+ "cardBg": "#1e1e24",
+ "infoBg": "#3c3728"
+ }
}
```
@@ -163,21 +164,22 @@ The optional `vars` section allows you to define reusable colors:
```json
{
- "vars": {
- "nord0": "#2E3440",
- "nord1": "#3B4252",
- "nord8": "#88C0D0",
- "brightBlue": 39
- },
- "colors": {
- "accent": "nord8",
- "muted": "nord1",
- "mdLink": "brightBlue"
- }
+ "vars": {
+ "nord0": "#2E3440",
+ "nord1": "#3B4252",
+ "nord8": "#88C0D0",
+ "brightBlue": 39
+ },
+ "colors": {
+ "accent": "nord8",
+ "muted": "nord1",
+ "mdLink": "brightBlue"
+ }
}
```
Benefits:
+
- Reuse colors across multiple tokens
- Easier to maintain theme consistency
- Can reference standard color palettes
@@ -190,13 +192,14 @@ Use `""` (empty string) to inherit the terminal's default foreground/background
```json
{
- "colors": {
- "text": "" // Uses terminal's default text color
- }
+ "colors": {
+ "text": "" // Uses terminal's default text color
+ }
}
```
This is useful for:
+
- Main text color (adapts to user's terminal theme)
- Creating themes that blend with terminal appearance
@@ -218,7 +221,7 @@ Themes are configured in the settings (accessible via `/settings`):
```json
{
- "theme": "dark"
+ "theme": "dark"
}
```
@@ -235,73 +238,76 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`.
### Creating a Custom Theme
1. **Create theme directory:**
+
```bash
mkdir -p ~/.pi/agent/themes
```
2. **Create theme file:**
+
```bash
vim ~/.pi/agent/themes/my-theme.json
```
3. **Define all colors:**
+
```json
{
- "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
- "name": "my-theme",
- "vars": {
- "primary": "#00aaff",
- "secondary": 242,
- "brightGreen": 46
- },
- "colors": {
- "accent": "primary",
- "border": "primary",
- "borderAccent": "#00ffff",
- "borderMuted": "secondary",
- "success": "brightGreen",
- "error": "#ff0000",
- "warning": "#ffff00",
- "muted": "secondary",
- "text": "",
-
- "userMessageBg": "#2d2d30",
- "userMessageText": "",
- "toolPendingBg": "#1e1e2e",
- "toolSuccessBg": "#1e2e1e",
- "toolErrorBg": "#2e1e1e",
- "toolText": "",
-
- "mdHeading": "#ffaa00",
- "mdLink": "primary",
- "mdCode": "#00ffff",
- "mdCodeBlock": "#00ff00",
- "mdCodeBlockBorder": "secondary",
- "mdQuote": "secondary",
- "mdQuoteBorder": "secondary",
- "mdHr": "secondary",
- "mdListBullet": "#00ffff",
-
- "toolDiffAdded": "#00ff00",
- "toolDiffRemoved": "#ff0000",
- "toolDiffContext": "secondary",
-
- "syntaxComment": "secondary",
- "syntaxKeyword": "primary",
- "syntaxFunction": "#00aaff",
- "syntaxVariable": "#ffaa00",
- "syntaxString": "#00ff00",
- "syntaxNumber": "#ff00ff",
- "syntaxType": "#00aaff",
- "syntaxOperator": "primary",
- "syntaxPunctuation": "secondary",
-
- "thinkingOff": "secondary",
- "thinkingMinimal": "primary",
- "thinkingLow": "#00aaff",
- "thinkingMedium": "#00ffff",
- "thinkingHigh": "#ff00ff"
- }
+ "$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
+ "name": "my-theme",
+ "vars": {
+ "primary": "#00aaff",
+ "secondary": 242,
+ "brightGreen": 46
+ },
+ "colors": {
+ "accent": "primary",
+ "border": "primary",
+ "borderAccent": "#00ffff",
+ "borderMuted": "secondary",
+ "success": "brightGreen",
+ "error": "#ff0000",
+ "warning": "#ffff00",
+ "muted": "secondary",
+ "text": "",
+
+ "userMessageBg": "#2d2d30",
+ "userMessageText": "",
+ "toolPendingBg": "#1e1e2e",
+ "toolSuccessBg": "#1e2e1e",
+ "toolErrorBg": "#2e1e1e",
+ "toolText": "",
+
+ "mdHeading": "#ffaa00",
+ "mdLink": "primary",
+ "mdCode": "#00ffff",
+ "mdCodeBlock": "#00ff00",
+ "mdCodeBlockBorder": "secondary",
+ "mdQuote": "secondary",
+ "mdQuoteBorder": "secondary",
+ "mdHr": "secondary",
+ "mdListBullet": "#00ffff",
+
+ "toolDiffAdded": "#00ff00",
+ "toolDiffRemoved": "#ff0000",
+ "toolDiffContext": "secondary",
+
+ "syntaxComment": "secondary",
+ "syntaxKeyword": "primary",
+ "syntaxFunction": "#00aaff",
+ "syntaxVariable": "#ffaa00",
+ "syntaxString": "#00ff00",
+ "syntaxNumber": "#ff00ff",
+ "syntaxType": "#00aaff",
+ "syntaxOperator": "primary",
+ "syntaxPunctuation": "secondary",
+
+ "thinkingOff": "secondary",
+ "thinkingMinimal": "primary",
+ "thinkingLow": "#00aaff",
+ "thinkingMedium": "#00ffff",
+ "thinkingHigh": "#ff00ff"
+ }
}
```
@@ -314,11 +320,13 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`.
### Light vs Dark Themes
**For dark terminals:**
+
- Use bright, saturated colors
- Higher contrast
- Example: `#00ffff` (bright cyan)
**For light terminals:**
+
- Use darker, muted colors
- Lower contrast to avoid eye strain
- Example: `#008888` (dark cyan)
@@ -332,6 +340,7 @@ Custom themes are loaded from `~/.pi/agent/themes/*.json`.
### Testing
Test your theme with:
+
- Different message types (user, assistant, errors)
- Tool executions (success and error states)
- Markdown content (headings, code, lists, etc)
@@ -342,6 +351,7 @@ Test your theme with:
### Hex Colors
Standard 6-digit hex format:
+
- `"#ff0000"` - Red
- `"#00ff00"` - Green
- `"#0000ff"` - Blue
@@ -356,6 +366,7 @@ RGB values: `#RRGGBB` where each component is `00-ff` (0-255)
Use numeric indices (0-255) to reference the xterm 256-color palette:
**Colors 0-15:** Basic ANSI colors (terminal-dependent, may be themed)
+
- `0` - Black
- `1` - Red
- `2` - Green
@@ -367,29 +378,33 @@ Use numeric indices (0-255) to reference the xterm 256-color palette:
- `8-15` - Bright variants
**Colors 16-231:** 6×6×6 RGB cube (standardized)
+
- Formula: `16 + 36×R + 6×G + B` where R, G, B are 0-5
- Example: `39` = bright cyan, `196` = bright red
**Colors 232-255:** Grayscale ramp (standardized)
+
- `232` - Darkest gray
- `255` - Near white
Example usage:
+
```json
{
- "vars": {
- "gray": 242,
- "brightCyan": 51,
- "darkBlue": 18
- },
- "colors": {
- "muted": "gray",
- "accent": "brightCyan"
- }
+ "vars": {
+ "gray": 242,
+ "brightCyan": 51,
+ "darkBlue": 18
+ },
+ "colors": {
+ "muted": "gray",
+ "accent": "brightCyan"
+ }
}
```
**Benefits:**
+
- Works everywhere (`TERM=xterm-256color`)
- No truecolor detection needed
- Standardized RGB cube (16-231) looks the same on all terminals
@@ -406,6 +421,7 @@ Pi uses 24-bit RGB colors (`\x1b[38;2;R;G;Bm`). Most modern terminals support th
For older terminals with only 256-color support, Pi automatically falls back to the nearest 256-color approximation.
To check if your terminal supports truecolor:
+
```bash
echo $COLORTERM # Should output "truecolor" or "24bit"
```
@@ -413,6 +429,7 @@ echo $COLORTERM # Should output "truecolor" or "24bit"
## Example Themes
See the built-in themes for complete examples:
+
- [Dark theme](../src/themes/dark.json)
- [Light theme](../src/themes/light.json)
@@ -421,6 +438,7 @@ See the built-in themes for complete examples:
Themes are validated on load using [TypeBox](https://github.com/sinclairzx81/typebox) + [Ajv](https://ajv.js.org/).
Invalid themes will show an error with details about what's wrong:
+
```
Error loading theme 'my-theme':
- colors.accent: must be string or number
@@ -428,11 +446,13 @@ Error loading theme 'my-theme':
```
For editor support, the JSON schema is available at:
+
```
https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json
```
Add to your theme file for auto-completion and validation:
+
```json
{
"$schema": "https://raw.githubusercontent.com/badlogic/pi-mono/main/packages/coding-agent/theme-schema.json",
@@ -448,16 +468,16 @@ Themes are loaded and converted to a `Theme` class that provides type-safe color
```typescript
class Theme {
- // Apply foreground color
- fg(color: ThemeColor, text: string): string
-
- // Apply background color
- bg(color: ThemeBg, text: string): string
-
- // Text attributes (preserve current colors)
- bold(text: string): string
- italic(text: string): string
- underline(text: string): string
+ // Apply foreground color
+ fg(color: ThemeColor, text: string): string;
+
+ // Apply background color
+ bg(color: ThemeBg, text: string): string;
+
+ // Text attributes (preserve current colors)
+ bold(text: string): string;
+ italic(text: string): string;
+ underline(text: string): string;
}
```
@@ -470,37 +490,37 @@ The active theme is available as a global singleton in `coding-agent`:
export let theme: Theme;
export function setTheme(name: string) {
- theme = loadTheme(name);
+ theme = loadTheme(name);
}
// Usage throughout coding-agent
-import { theme } from './theme.js';
+import { theme } from "./theme.js";
-theme.fg('accent', 'Selected')
-theme.bg('userMessageBg', content)
+theme.fg("accent", "Selected");
+theme.bg("userMessageBg", content);
```
### TUI Component Theming
-TUI components (like `Markdown`, `SelectList`, `Editor`) are in the `@mariozechner/pi-tui` package and don't have direct access to the theme. Instead, they define interfaces for the colors they need:
+TUI components (like `Markdown`, `SelectList`, `Editor`) are in the `@oh-my-pi/pi-tui` package and don't have direct access to the theme. Instead, they define interfaces for the colors they need:
```typescript
-// In @mariozechner/pi-tui
+// In @oh-my-pi/pi-tui
export interface MarkdownTheme {
- heading: (text: string) => string;
- link: (text: string) => string;
- linkUrl: (text: string) => string;
- code: (text: string) => string;
- codeBlock: (text: string) => string;
- codeBlockBorder: (text: string) => string;
- quote: (text: string) => string;
- quoteBorder: (text: string) => string;
- hr: (text: string) => string;
- listBullet: (text: string) => string;
- bold: (text: string) => string;
- italic: (text: string) => string;
- strikethrough: (text: string) => string;
- underline: (text: string) => string;
+ heading: (text: string) => string;
+ link: (text: string) => string;
+ linkUrl: (text: string) => string;
+ code: (text: string) => string;
+ codeBlock: (text: string) => string;
+ codeBlockBorder: (text: string) => string;
+ quote: (text: string) => string;
+ quoteBorder: (text: string) => string;
+ hr: (text: string) => string;
+ listBullet: (text: string) => string;
+ bold: (text: string) => string;
+ italic: (text: string) => string;
+ strikethrough: (text: string) => string;
+ underline: (text: string) => string;
}
```
@@ -508,70 +528,66 @@ The `coding-agent` provides themed functions when creating components:
```typescript
// In coding-agent
-import { theme } from './theme.js';
-import { Markdown } from '@mariozechner/pi-tui';
+import { theme } from "./theme.js";
+import { Markdown } from "@oh-my-pi/pi-tui";
// Helper to create markdown theme functions
function getMarkdownTheme(): MarkdownTheme {
- return {
- heading: (text) => theme.fg('mdHeading', text),
- link: (text) => theme.fg('mdLink', text),
- linkUrl: (text) => theme.fg('mdLinkUrl', text),
- code: (text) => theme.fg('mdCode', text),
- codeBlock: (text) => theme.fg('mdCodeBlock', text),
- codeBlockBorder: (text) => theme.fg('mdCodeBlockBorder', text),
- quote: (text) => theme.fg('mdQuote', text),
- quoteBorder: (text) => theme.fg('mdQuoteBorder', text),
- hr: (text) => theme.fg('mdHr', text),
- listBullet: (text) => theme.fg('mdListBullet', text),
- bold: (text) => theme.bold(text),
- italic: (text) => theme.italic(text),
- underline: (text) => theme.underline(text),
- strikethrough: (text) => chalk.strikethrough(text),
- };
+ return {
+ heading: (text) => theme.fg("mdHeading", text),
+ link: (text) => theme.fg("mdLink", text),
+ linkUrl: (text) => theme.fg("mdLinkUrl", text),
+ code: (text) => theme.fg("mdCode", text),
+ codeBlock: (text) => theme.fg("mdCodeBlock", text),
+ codeBlockBorder: (text) => theme.fg("mdCodeBlockBorder", text),
+ quote: (text) => theme.fg("mdQuote", text),
+ quoteBorder: (text) => theme.fg("mdQuoteBorder", text),
+ hr: (text) => theme.fg("mdHr", text),
+ listBullet: (text) => theme.fg("mdListBullet", text),
+ bold: (text) => theme.bold(text),
+ italic: (text) => theme.italic(text),
+ underline: (text) => theme.underline(text),
+ strikethrough: (text) => chalk.strikethrough(text),
+ };
}
// Create markdown with theme
-const md = new Markdown(
- text,
- 1, 1,
- { bgColor: theme.bg('userMessageBg') },
- getMarkdownTheme()
-);
+const md = new Markdown(text, 1, 1, { bgColor: theme.bg("userMessageBg") }, getMarkdownTheme());
```
This approach:
+
- Keeps TUI components theme-agnostic (reusable in other projects)
- Maintains type safety via interfaces
- Allows components to have sensible defaults if no theme provided
- Centralizes theme access in `coding-agent`
**Example usage:**
+
```typescript
-const theme = loadTheme('dark');
+const theme = loadTheme("dark");
// Apply foreground colors
-theme.fg('accent', 'Selected')
-theme.fg('success', '✓ Done')
-theme.fg('error', 'Failed')
+theme.fg("accent", "Selected");
+theme.fg("success", "✓ Done");
+theme.fg("error", "Failed");
// Apply background colors
-theme.bg('userMessageBg', content)
-theme.bg('toolSuccessBg', output)
+theme.bg("userMessageBg", content);
+theme.bg("toolSuccessBg", output);
// Combine styles
-theme.bold(theme.fg('accent', 'Title'))
-theme.italic(theme.fg('muted', 'metadata'))
+theme.bold(theme.fg("accent", "Title"));
+theme.italic(theme.fg("muted", "metadata"));
// Nested foreground + background
-const userMsg = theme.bg('userMessageBg',
- theme.fg('userMessageText', 'Hello')
-)
+const userMsg = theme.bg("userMessageBg", theme.fg("userMessageText", "Hello"));
```
**Color resolution:**
1. **Detect terminal capabilities:**
+
- Check `$COLORTERM` env var (`truecolor` or `24bit` → truecolor support)
- Check `$TERM` env var (`*-256color` → 256-color support)
- Fallback to 256-color mode if detection fails
@@ -579,37 +595,41 @@ const userMsg = theme.bg('userMessageBg',
2. **Load JSON theme file**
3. **Resolve `vars` references recursively:**
+
```json
{
- "vars": {
- "primary": "#0066cc",
- "accent": "primary"
- },
- "colors": {
- "accent": "accent" // → "primary" → "#0066cc"
- }
+ "vars": {
+ "primary": "#0066cc",
+ "accent": "primary"
+ },
+ "colors": {
+ "accent": "accent" // → "primary" → "#0066cc"
+ }
}
```
4. **Convert colors to ANSI codes based on terminal capability:**
-
+
**Truecolor mode (24-bit):**
+
- Hex (`"#ff0000"`) → `\x1b[38;2;255;0;0m`
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
- Empty string (`""`) → `\x1b[39m`
-
+
**256-color mode:**
+
- Hex (`"#ff0000"`) → convert to nearest RGB cube color → `\x1b[38;5;196m`
- 256-color (`42`) → `\x1b[38;5;42m` (keep as-is)
- Empty string (`""`) → `\x1b[39m`
-
+
**Hex to 256-color conversion:**
+
```typescript
// Convert RGB to 6x6x6 cube (colors 16-231)
- r_index = Math.round(r / 255 * 5)
- g_index = Math.round(g / 255 * 5)
- b_index = Math.round(b / 255 * 5)
- color_index = 16 + 36 * r_index + 6 * g_index + b_index
+ r_index = Math.round((r / 255) * 5);
+ g_index = Math.round((g / 255) * 5);
+ b_index = Math.round((b / 255) * 5);
+ color_index = 16 + 36 * r_index + 6 * g_index + b_index;
```
5. **Cache as `Theme` instance**
diff --git a/packages/coding-agent/docs/tui.md b/packages/coding-agent/docs/tui.md
index 297f6cf5d..8f6d15485 100644
--- a/packages/coding-agent/docs/tui.md
+++ b/packages/coding-agent/docs/tui.md
@@ -4,7 +4,7 @@
Hooks and custom tools can render custom TUI components for interactive user interfaces. This page covers the component system and available building blocks.
-**Source:** [`@mariozechner/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
+**Source:** [`@oh-my-pi/pi-tui`](https://github.com/badlogic/pi-mono/tree/main/packages/tui)
## Component Interface
@@ -12,17 +12,17 @@ All components implement:
```typescript
interface Component {
- render(width: number): string[];
- handleInput?(data: string): void;
- invalidate?(): void;
+ render(width: number): string[];
+ handleInput?(data: string): void;
+ invalidate?(): void;
}
```
-| Method | Description |
-|--------|-------------|
-| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
-| `handleInput?(data)` | Receive keyboard input when component has focus. |
-| `invalidate?()` | Clear cached render state. |
+| Method | Description |
+| -------------------- | ------------------------------------------------------------------------------ |
+| `render(width)` | Return array of strings (one per line). Each line **must not exceed `width`**. |
+| `handleInput?(data)` | Receive keyboard input when component has focus. |
+| `invalidate?()` | Clear cached render state. |
## Using Components
@@ -30,9 +30,9 @@ interface Component {
```typescript
pi.on("session_start", async (_event, ctx) => {
- const handle = ctx.ui.custom(myComponent);
- // handle.requestRender() - trigger re-render
- // handle.close() - restore normal UI
+ const handle = ctx.ui.custom(myComponent);
+ // handle.requestRender() - trigger re-render
+ // handle.close() - restore normal UI
});
```
@@ -48,10 +48,10 @@ async execute(toolCallId, params, onUpdate, ctx, signal) {
## Built-in Components
-Import from `@mariozechner/pi-tui`:
+Import from `@oh-my-pi/pi-tui`:
```typescript
-import { Text, Box, Container, Spacer, Markdown } from "@mariozechner/pi-tui";
+import { Text, Box, Container, Spacer, Markdown } from "@oh-my-pi/pi-tui";
```
### Text
@@ -60,10 +60,10 @@ Multi-line text with word wrapping.
```typescript
const text = new Text(
- "Hello World", // content
- 1, // paddingX (default: 1)
- 1, // paddingY (default: 1)
- (s) => bgGray(s) // optional background function
+ "Hello World", // content
+ 1, // paddingX (default: 1)
+ 1, // paddingY (default: 1)
+ (s) => bgGray(s) // optional background function
);
text.setText("Updated");
```
@@ -74,9 +74,9 @@ Container with padding and background color.
```typescript
const box = new Box(
- 1, // paddingX
- 1, // paddingY
- (s) => bgGray(s) // background function
+ 1, // paddingX
+ 1, // paddingY
+ (s) => bgGray(s) // background function
);
box.addChild(new Text("Content", 0, 0));
box.setBgFn((s) => bgBlue(s));
@@ -98,7 +98,7 @@ container.removeChild(component1);
Empty vertical space.
```typescript
-const spacer = new Spacer(2); // 2 empty lines
+const spacer = new Spacer(2); // 2 empty lines
```
### Markdown
@@ -107,10 +107,10 @@ Renders markdown with syntax highlighting.
```typescript
const md = new Markdown(
- "# Title\n\nSome **bold** text",
- 1, // paddingX
- 1, // paddingY
- theme // MarkdownTheme (see below)
+ "# Title\n\nSome **bold** text",
+ 1, // paddingX
+ 1, // paddingY
+ theme // MarkdownTheme (see below)
);
md.setText("Updated markdown");
```
@@ -121,10 +121,10 @@ Renders images in supported terminals (Kitty, iTerm2, Ghostty, WezTerm).
```typescript
const image = new Image(
- base64Data, // base64-encoded image
- "image/png", // MIME type
- theme, // ImageTheme
- { maxWidthCells: 80, maxHeightCells: 24 }
+ base64Data, // base64-encoded image
+ "image/png", // MIME type
+ theme, // ImageTheme
+ { maxWidthCells: 80, maxHeightCells: 24 }
);
```
@@ -138,7 +138,7 @@ import {
isArrowUp, isArrowDown, isArrowLeft, isArrowRight,
isCtrlC, isCtrlO, isBackspace, isDelete,
// ... and more
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
handleInput(data: string) {
if (isArrowUp(data)) {
@@ -156,7 +156,7 @@ handleInput(data: string) {
**Critical:** Each line from `render()` must not exceed the `width` parameter.
```typescript
-import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
+import { visibleWidth, truncateToWidth } from "@oh-my-pi/pi-tui";
render(width: number): string[] {
// Truncate long lines
@@ -165,6 +165,7 @@ render(width: number): string[] {
```
Utilities:
+
- `visibleWidth(str)` - Get display width (ignores ANSI codes)
- `truncateToWidth(str, width, ellipsis?)` - Truncate with optional ellipsis
- `wrapTextWithAnsi(str, width)` - Word wrap preserving ANSI codes
@@ -174,55 +175,52 @@ Utilities:
Example: Interactive selector
```typescript
-import {
- isEnter, isEscape, isArrowUp, isArrowDown,
- truncateToWidth, visibleWidth
-} from "@mariozechner/pi-tui";
+import { isEnter, isEscape, isArrowUp, isArrowDown, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
class MySelector {
- private items: string[];
- private selected = 0;
- private cachedWidth?: number;
- private cachedLines?: string[];
-
- public onSelect?: (item: string) => void;
- public onCancel?: () => void;
+ private items: string[];
+ private selected = 0;
+ private cachedWidth?: number;
+ private cachedLines?: string[];
- constructor(items: string[]) {
- this.items = items;
- }
+ public onSelect?: (item: string) => void;
+ public onCancel?: () => void;
- handleInput(data: string): void {
- if (isArrowUp(data) && this.selected > 0) {
- this.selected--;
- this.invalidate();
- } else if (isArrowDown(data) && this.selected < this.items.length - 1) {
- this.selected++;
- this.invalidate();
- } else if (isEnter(data)) {
- this.onSelect?.(this.items[this.selected]);
- } else if (isEscape(data)) {
- this.onCancel?.();
- }
- }
+ constructor(items: string[]) {
+ this.items = items;
+ }
- render(width: number): string[] {
- if (this.cachedLines && this.cachedWidth === width) {
- return this.cachedLines;
- }
+ handleInput(data: string): void {
+ if (isArrowUp(data) && this.selected > 0) {
+ this.selected--;
+ this.invalidate();
+ } else if (isArrowDown(data) && this.selected < this.items.length - 1) {
+ this.selected++;
+ this.invalidate();
+ } else if (isEnter(data)) {
+ this.onSelect?.(this.items[this.selected]);
+ } else if (isEscape(data)) {
+ this.onCancel?.();
+ }
+ }
- this.cachedLines = this.items.map((item, i) => {
- const prefix = i === this.selected ? "> " : " ";
- return truncateToWidth(prefix + item, width);
- });
- this.cachedWidth = width;
- return this.cachedLines;
- }
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) {
+ return this.cachedLines;
+ }
- invalidate(): void {
- this.cachedWidth = undefined;
- this.cachedLines = undefined;
- }
+ this.cachedLines = this.items.map((item, i) => {
+ const prefix = i === this.selected ? "> " : " ";
+ return truncateToWidth(prefix + item, width);
+ });
+ this.cachedWidth = width;
+ return this.cachedLines;
+ }
+
+ invalidate(): void {
+ this.cachedWidth = undefined;
+ this.cachedLines = undefined;
+ }
}
```
@@ -230,26 +228,26 @@ Usage in a hook:
```typescript
pi.registerCommand("pick", {
- description: "Pick an item",
- handler: async (args, ctx) => {
- const items = ["Option A", "Option B", "Option C"];
- const selector = new MySelector(items);
-
- let handle: { close: () => void; requestRender: () => void };
-
- await new Promise((resolve) => {
- selector.onSelect = (item) => {
- ctx.ui.notify(`Selected: ${item}`, "info");
- handle.close();
- resolve();
- };
- selector.onCancel = () => {
- handle.close();
- resolve();
- };
- handle = ctx.ui.custom(selector);
- });
- }
+ description: "Pick an item",
+ handler: async (args, ctx) => {
+ const items = ["Option A", "Option B", "Option C"];
+ const selector = new MySelector(items);
+
+ let handle: { close: () => void; requestRender: () => void };
+
+ await new Promise((resolve) => {
+ selector.onSelect = (item) => {
+ ctx.ui.notify(`Selected: ${item}`, "info");
+ handle.close();
+ resolve();
+ };
+ selector.onCancel = () => {
+ handle.close();
+ resolve();
+ };
+ handle = ctx.ui.custom(selector);
+ });
+ },
});
```
@@ -263,7 +261,7 @@ Components accept theme objects for styling.
renderResult(result, options, theme) {
// Use theme.fg() for foreground colors
return new Text(theme.fg("success", "Done!"), 0, 0);
-
+
// Use theme.bg() for background colors
const styled = theme.bg("toolPendingBg", theme.fg("accent", "text"));
}
@@ -271,18 +269,18 @@ renderResult(result, options, theme) {
**Foreground colors** (`theme.fg(color, text)`):
-| Category | Colors |
-|----------|--------|
-| General | `text`, `accent`, `muted`, `dim` |
-| Status | `success`, `error`, `warning` |
-| Borders | `border`, `borderAccent`, `borderMuted` |
-| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
-| Tools | `toolTitle`, `toolOutput` |
-| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
-| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
-| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
-| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
-| Modes | `bashMode` |
+| Category | Colors |
+| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| General | `text`, `accent`, `muted`, `dim` |
+| Status | `success`, `error`, `warning` |
+| Borders | `border`, `borderAccent`, `borderMuted` |
+| Messages | `userMessageText`, `customMessageText`, `customMessageLabel` |
+| Tools | `toolTitle`, `toolOutput` |
+| Diffs | `toolDiffAdded`, `toolDiffRemoved`, `toolDiffContext` |
+| Markdown | `mdHeading`, `mdLink`, `mdLinkUrl`, `mdCode`, `mdCodeBlock`, `mdCodeBlockBorder`, `mdQuote`, `mdQuoteBorder`, `mdHr`, `mdListBullet` |
+| Syntax | `syntaxComment`, `syntaxKeyword`, `syntaxFunction`, `syntaxVariable`, `syntaxString`, `syntaxNumber`, `syntaxType`, `syntaxOperator`, `syntaxPunctuation` |
+| Thinking | `thinkingOff`, `thinkingMinimal`, `thinkingLow`, `thinkingMedium`, `thinkingHigh`, `thinkingXhigh` |
+| Modes | `bashMode` |
**Background colors** (`theme.bg(color, text)`):
@@ -291,8 +289,8 @@ renderResult(result, options, theme) {
**For Markdown**, use `getMarkdownTheme()`:
```typescript
-import { getMarkdownTheme } from "@mariozechner/pi-coding-agent";
-import { Markdown } from "@mariozechner/pi-tui";
+import { getMarkdownTheme } from "@oh-my-pi/pi-coding-agent";
+import { Markdown } from "@oh-my-pi/pi-tui";
renderResult(result, options, theme) {
const mdTheme = getMarkdownTheme();
@@ -304,8 +302,8 @@ renderResult(result, options, theme) {
```typescript
interface MyTheme {
- selected: (s: string) => string;
- normal: (s: string) => string;
+ selected: (s: string) => string;
+ normal: (s: string) => string;
}
```
@@ -315,23 +313,23 @@ Cache rendered output when possible:
```typescript
class CachedComponent {
- private cachedWidth?: number;
- private cachedLines?: string[];
+ private cachedWidth?: number;
+ private cachedLines?: string[];
- render(width: number): string[] {
- if (this.cachedLines && this.cachedWidth === width) {
- return this.cachedLines;
- }
- // ... compute lines ...
- this.cachedWidth = width;
- this.cachedLines = lines;
- return lines;
- }
+ render(width: number): string[] {
+ if (this.cachedLines && this.cachedWidth === width) {
+ return this.cachedLines;
+ }
+ // ... compute lines ...
+ this.cachedWidth = width;
+ this.cachedLines = lines;
+ return lines;
+ }
- invalidate(): void {
- this.cachedWidth = undefined;
- this.cachedLines = undefined;
- }
+ invalidate(): void {
+ this.cachedWidth = undefined;
+ this.cachedLines = undefined;
+ }
}
```
diff --git a/packages/coding-agent/examples/custom-tools/README.md b/packages/coding-agent/examples/custom-tools/README.md
index d3be06364..bcd1164d0 100644
--- a/packages/coding-agent/examples/custom-tools/README.md
+++ b/packages/coding-agent/examples/custom-tools/README.md
@@ -7,20 +7,26 @@ Example custom tools for pi-coding-agent.
Each example uses the `subdirectory/index.ts` structure required for tool discovery.
### hello/
+
Minimal example showing the basic structure of a custom tool.
### question/
+
Demonstrates `pi.ui.select()` for asking the user questions with options.
### todo/
+
Full-featured example demonstrating:
+
- `onSession` for state reconstruction from session history
- Custom `renderCall` and `renderResult`
- Proper branching support via details storage
- State management without external files
### subagent/
+
Delegate tasks to specialized subagents with isolated context windows. Includes:
+
- `index.ts` - The custom tool (single, parallel, and chain modes)
- `agents.ts` - Agent discovery helper
- `agents/` - Sample agent definitions (scout, planner, reviewer, worker)
@@ -39,6 +45,7 @@ cp -r todo ~/.pi/agent/tools/
```
Then in pi:
+
```
> add a todo "test custom tools"
> list todos
@@ -53,37 +60,41 @@ See [docs/custom-tools.md](../../docs/custom-tools.md) for full documentation.
### Key Points
**Factory pattern:**
+
```typescript
import { Type } from "@sinclair/typebox";
-import { StringEnum } from "@mariozechner/pi-ai";
-import { Text } from "@mariozechner/pi-tui";
-import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import { StringEnum } from "@oh-my-pi/pi-ai";
+import { Text } from "@oh-my-pi/pi-tui";
+import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
- name: "my_tool",
- label: "My Tool",
- description: "Tool description for LLM",
- parameters: Type.Object({
- action: StringEnum(["list", "add"] as const),
- }),
-
- // Called on session start/switch/branch/clear
- onSession(event) {
- // Reconstruct state from event.entries
- },
-
- async execute(toolCallId, params) {
- return {
- content: [{ type: "text", text: "Result" }],
- details: { /* for rendering and state reconstruction */ },
- };
- },
+ name: "my_tool",
+ label: "My Tool",
+ description: "Tool description for LLM",
+ parameters: Type.Object({
+ action: StringEnum(["list", "add"] as const),
+ }),
+
+ // Called on session start/switch/branch/clear
+ onSession(event) {
+ // Reconstruct state from event.entries
+ },
+
+ async execute(toolCallId, params) {
+ return {
+ content: [{ type: "text", text: "Result" }],
+ details: {
+ /* for rendering and state reconstruction */
+ },
+ };
+ },
});
export default factory;
```
**Custom rendering:**
+
```typescript
renderCall(args, theme) {
return new Text(
@@ -101,12 +112,13 @@ renderResult(result, { expanded, isPartial }, theme) {
```
**Use StringEnum for string parameters** (required for Google API compatibility):
+
```typescript
-import { StringEnum } from "@mariozechner/pi-ai";
+import { StringEnum } from "@oh-my-pi/pi-ai";
// Good
-action: StringEnum(["list", "add"] as const)
+action: StringEnum(["list", "add"] as const);
// Bad - doesn't work with Google
-action: Type.Union([Type.Literal("list"), Type.Literal("add")])
+action: Type.Union([Type.Literal("list"), Type.Literal("add")]);
```
diff --git a/packages/coding-agent/examples/custom-tools/hello/index.ts b/packages/coding-agent/examples/custom-tools/hello/index.ts
index 19cf9e655..926e98f84 100644
--- a/packages/coding-agent/examples/custom-tools/hello/index.ts
+++ b/packages/coding-agent/examples/custom-tools/hello/index.ts
@@ -1,4 +1,4 @@
-import type { CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import type { CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
const factory: CustomToolFactory = (pi) => ({
name: "hello",
diff --git a/packages/coding-agent/examples/custom-tools/question/index.ts b/packages/coding-agent/examples/custom-tools/question/index.ts
index 2ccbb53d1..8b8a043d8 100644
--- a/packages/coding-agent/examples/custom-tools/question/index.ts
+++ b/packages/coding-agent/examples/custom-tools/question/index.ts
@@ -2,7 +2,7 @@
* Question Tool - Let the LLM ask the user a question with options
*/
-import type { CustomTool, CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import type { CustomTool, CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
interface QuestionDetails {
question: string;
diff --git a/packages/coding-agent/examples/custom-tools/subagent/index.ts b/packages/coding-agent/examples/custom-tools/subagent/index.ts
index fd75bd78a..cd9b0f086 100644
--- a/packages/coding-agent/examples/custom-tools/subagent/index.ts
+++ b/packages/coding-agent/examples/custom-tools/subagent/index.ts
@@ -15,9 +15,9 @@
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 type { CustomTool, CustomToolAPI, CustomToolFactory } from "@mariozechner/pi-coding-agent";
+import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
+import type { Message } from "@oh-my-pi/pi-ai";
+import type { CustomTool, CustomToolAPI, CustomToolFactory } from "@oh-my-pi/pi-coding-agent";
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
const MAX_PARALLEL_TASKS = 8;
@@ -755,7 +755,10 @@ const factory: CustomToolFactory = (pi) => {
if (expanded) {
const container = new Container();
- let header = `${icon} ${theme.fg("toolTitle", theme.bold(r.agent))}${theme.fg("muted", ` (${r.agentSource})`)}`;
+ 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)
diff --git a/packages/coding-agent/examples/custom-tools/todo/index.ts b/packages/coding-agent/examples/custom-tools/todo/index.ts
index 8e414a7fa..6ab76b7df 100644
--- a/packages/coding-agent/examples/custom-tools/todo/index.ts
+++ b/packages/coding-agent/examples/custom-tools/todo/index.ts
@@ -13,7 +13,7 @@ import type {
CustomToolContext,
CustomToolFactory,
CustomToolSessionEvent,
-} from "@mariozechner/pi-coding-agent";
+} from "@oh-my-pi/pi-coding-agent";
interface Todo {
id: number;
@@ -188,10 +188,7 @@ const factory: CustomToolFactory = (pi) => {
case "add": {
const added = todoList[todoList.length - 1];
return new Text(
- theme.fg("success", "✓ Added ") +
- theme.fg("accent", `#${added.id}`) +
- " " +
- theme.fg("muted", added.text),
+ `${theme.fg("success", "✓ Added ") + theme.fg("accent", `#${added.id}`)} ${theme.fg("muted", added.text)}`,
0,
0,
);
diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md
index 70f84fe87..336994669 100644
--- a/packages/coding-agent/examples/hooks/README.md
+++ b/packages/coding-agent/examples/hooks/README.md
@@ -14,43 +14,43 @@ cp permission-gate.ts ~/.pi/agent/hooks/
## Examples
-| Hook | Description |
-|------|-------------|
-| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
-| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
-| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
-| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
-| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
-| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
-| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
-| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
-| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
-| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
-| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
-| `handoff.ts` | Transfer context to a new focused session via `/handoff ` |
+| Hook | Description |
+| ------------------------ | ------------------------------------------------------------------------------ |
+| `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) |
+| `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch |
+| `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) |
+| `file-trigger.ts` | Watches a trigger file and injects contents into conversation |
+| `confirm-destructive.ts` | Confirms before destructive session actions (clear, switch, branch) |
+| `dirty-repo-guard.ts` | Prevents session changes with uncommitted git changes |
+| `auto-commit-on-exit.ts` | Auto-commits on exit using last assistant message for commit message |
+| `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
+| `qna.ts` | Extracts questions from last response into editor via `ctx.ui.setEditorText()` |
+| `snake.ts` | Snake game with custom UI, keyboard handling, and session persistence |
+| `status-line.ts` | Shows turn progress in footer via `ctx.ui.setStatus()` with themed colors |
+| `handoff.ts` | Transfer context to a new focused session via `/handoff ` |
## Writing Hooks
See [docs/hooks.md](../../docs/hooks.md) for full documentation.
```typescript
-import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent/hooks";
export default function (pi: HookAPI) {
- // Subscribe to 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" };
- }
- });
+ // Subscribe to 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 commands
- pi.registerCommand("hello", {
- description: "Say hello",
- handler: async (args, ctx) => {
- ctx.ui.notify("Hello!", "info");
- },
- });
+ // Register custom commands
+ pi.registerCommand("hello", {
+ description: "Say hello",
+ handler: async (args, ctx) => {
+ ctx.ui.notify("Hello!", "info");
+ },
+ });
}
```
diff --git a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
index 598ecdc27..ece2d13d8 100644
--- a/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
+++ b/packages/coding-agent/examples/hooks/auto-commit-on-exit.ts
@@ -5,7 +5,7 @@
* Uses the last assistant message to generate a commit message.
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_shutdown", async (_event, ctx) => {
diff --git a/packages/coding-agent/examples/hooks/confirm-destructive.ts b/packages/coding-agent/examples/hooks/confirm-destructive.ts
index ff213929e..9da9b0a95 100644
--- a/packages/coding-agent/examples/hooks/confirm-destructive.ts
+++ b/packages/coding-agent/examples/hooks/confirm-destructive.ts
@@ -5,7 +5,7 @@
* Demonstrates how to cancel session events using the before_* events.
*/
-import type { HookAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@mariozechner/pi-coding-agent";
+import type { HookAPI, SessionBeforeSwitchEvent, SessionMessageEntry } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_before_switch", async (event: SessionBeforeSwitchEvent, ctx) => {
diff --git a/packages/coding-agent/examples/hooks/custom-compaction.ts b/packages/coding-agent/examples/hooks/custom-compaction.ts
index 5f413e031..752536dab 100644
--- a/packages/coding-agent/examples/hooks/custom-compaction.ts
+++ b/packages/coding-agent/examples/hooks/custom-compaction.ts
@@ -13,9 +13,9 @@
* pi --hook examples/hooks/custom-compaction.ts
*/
-import { complete, getModel } from "@mariozechner/pi-ai";
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
-import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { complete, getModel } from "@oh-my-pi/pi-ai";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
+import { convertToLlm, serializeConversation } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_before_compact", async (event, ctx) => {
@@ -42,7 +42,9 @@ export default function (pi: HookAPI) {
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
ctx.ui.notify(
- `Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${model.id}...`,
+ `Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${
+ model.id
+ }...`,
"info",
);
diff --git a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
index 49335c72d..99f7692fc 100644
--- a/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
+++ b/packages/coding-agent/examples/hooks/dirty-repo-guard.ts
@@ -5,7 +5,7 @@
* Useful to ensure work is committed before switching context.
*/
-import type { HookAPI, HookContext } from "@mariozechner/pi-coding-agent";
+import type { HookAPI, HookContext } from "@oh-my-pi/pi-coding-agent";
async function checkDirtyRepo(pi: HookAPI, ctx: HookContext, action: string): Promise<{ cancel: boolean } | undefined> {
// Check for uncommitted changes
diff --git a/packages/coding-agent/examples/hooks/file-trigger.ts b/packages/coding-agent/examples/hooks/file-trigger.ts
index e3f69b1f5..1623533c4 100644
--- a/packages/coding-agent/examples/hooks/file-trigger.ts
+++ b/packages/coding-agent/examples/hooks/file-trigger.ts
@@ -9,7 +9,7 @@
*/
import * as fs from "node:fs";
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
pi.on("session_start", async (_event, ctx) => {
diff --git a/packages/coding-agent/examples/hooks/git-checkpoint.ts b/packages/coding-agent/examples/hooks/git-checkpoint.ts
index 1ea89449b..11d84cf03 100644
--- a/packages/coding-agent/examples/hooks/git-checkpoint.ts
+++ b/packages/coding-agent/examples/hooks/git-checkpoint.ts
@@ -5,7 +5,7 @@
* When branching, offers to restore code to that point in history.
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
const checkpoints = new Map();
diff --git a/packages/coding-agent/examples/hooks/handoff.ts b/packages/coding-agent/examples/hooks/handoff.ts
index 8817947ff..4b3cc316c 100644
--- a/packages/coding-agent/examples/hooks/handoff.ts
+++ b/packages/coding-agent/examples/hooks/handoff.ts
@@ -12,9 +12,9 @@
* The generated prompt appears as a draft in the editor for review/editing.
*/
-import { complete, type Message } from "@mariozechner/pi-ai";
-import type { HookAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
-import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
+import { complete, type Message } from "@oh-my-pi/pi-ai";
+import type { HookAPI, SessionEntry } from "@oh-my-pi/pi-coding-agent";
+import { BorderedLoader, convertToLlm, serializeConversation } from "@oh-my-pi/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:
diff --git a/packages/coding-agent/examples/hooks/permission-gate.ts b/packages/coding-agent/examples/hooks/permission-gate.ts
index c3619fd0e..280f09f05 100644
--- a/packages/coding-agent/examples/hooks/permission-gate.ts
+++ b/packages/coding-agent/examples/hooks/permission-gate.ts
@@ -5,7 +5,7 @@
* Patterns checked: rm -rf, sudo, chmod/chown 777
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
const dangerousPatterns = [/\brm\s+(-rf?|--recursive)/i, /\bsudo\b/i, /\b(chmod|chown)\b.*777/i];
diff --git a/packages/coding-agent/examples/hooks/protected-paths.ts b/packages/coding-agent/examples/hooks/protected-paths.ts
index 8431d2fb0..e296bdf2c 100644
--- a/packages/coding-agent/examples/hooks/protected-paths.ts
+++ b/packages/coding-agent/examples/hooks/protected-paths.ts
@@ -5,7 +5,7 @@
* Useful for preventing accidental modifications to sensitive files.
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
const protectedPaths = [".env", ".git/", "node_modules/"];
diff --git a/packages/coding-agent/examples/hooks/qna.ts b/packages/coding-agent/examples/hooks/qna.ts
index 92bb14d78..1200b0fb2 100644
--- a/packages/coding-agent/examples/hooks/qna.ts
+++ b/packages/coding-agent/examples/hooks/qna.ts
@@ -7,9 +7,9 @@
* 3. Loads the result into the editor for user to fill in answers
*/
-import { complete, type UserMessage } from "@mariozechner/pi-ai";
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
-import { BorderedLoader } from "@mariozechner/pi-coding-agent";
+import { complete, type UserMessage } from "@oh-my-pi/pi-ai";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
+import { BorderedLoader } from "@oh-my-pi/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.
diff --git a/packages/coding-agent/examples/hooks/snake.ts b/packages/coding-agent/examples/hooks/snake.ts
index c90cb151e..62b4b412d 100644
--- a/packages/coding-agent/examples/hooks/snake.ts
+++ b/packages/coding-agent/examples/hooks/snake.ts
@@ -2,8 +2,8 @@
* Snake game hook - play snake with /snake command
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
-import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@mariozechner/pi-tui";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
+import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp, isEscape, visibleWidth } from "@oh-my-pi/pi-tui";
const GAME_WIDTH = 40;
const GAME_HEIGHT = 15;
diff --git a/packages/coding-agent/examples/hooks/status-line.ts b/packages/coding-agent/examples/hooks/status-line.ts
index 000000626..5c9b643fc 100644
--- a/packages/coding-agent/examples/hooks/status-line.ts
+++ b/packages/coding-agent/examples/hooks/status-line.ts
@@ -5,7 +5,7 @@
* Shows turn progress with themed colors.
*/
-import type { HookAPI } from "@mariozechner/pi-coding-agent";
+import type { HookAPI } from "@oh-my-pi/pi-coding-agent";
export default function (pi: HookAPI) {
let turnCount = 0;
diff --git a/packages/coding-agent/examples/sdk/01-minimal.ts b/packages/coding-agent/examples/sdk/01-minimal.ts
index 80045132c..8df58a1cb 100644
--- a/packages/coding-agent/examples/sdk/01-minimal.ts
+++ b/packages/coding-agent/examples/sdk/01-minimal.ts
@@ -5,7 +5,7 @@
* from cwd and ~/.pi/agent. Model chosen from settings or first available.
*/
-import { createAgentSession } from "@mariozechner/pi-coding-agent";
+import { createAgentSession } from "@oh-my-pi/pi-coding-agent";
const { session } = await createAgentSession();
diff --git a/packages/coding-agent/examples/sdk/02-custom-model.ts b/packages/coding-agent/examples/sdk/02-custom-model.ts
index 5d5bf656c..4752f7534 100644
--- a/packages/coding-agent/examples/sdk/02-custom-model.ts
+++ b/packages/coding-agent/examples/sdk/02-custom-model.ts
@@ -4,8 +4,8 @@
* Shows how to select a specific model and thinking level.
*/
-import { getModel } from "@mariozechner/pi-ai";
-import { createAgentSession, discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
+import { getModel } from "@oh-my-pi/pi-ai";
+import { createAgentSession, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
// Set up auth storage and model registry
const authStorage = discoverAuthStorage();
diff --git a/packages/coding-agent/examples/sdk/03-custom-prompt.ts b/packages/coding-agent/examples/sdk/03-custom-prompt.ts
index 37698f46d..19aa9fd32 100644
--- a/packages/coding-agent/examples/sdk/03-custom-prompt.ts
+++ b/packages/coding-agent/examples/sdk/03-custom-prompt.ts
@@ -4,7 +4,7 @@
* Shows how to replace or modify the default system prompt.
*/
-import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Option 1: Replace prompt entirely
const { session: session1 } = await createAgentSession({
diff --git a/packages/coding-agent/examples/sdk/04-skills.ts b/packages/coding-agent/examples/sdk/04-skills.ts
index bf04633fa..054fb7209 100644
--- a/packages/coding-agent/examples/sdk/04-skills.ts
+++ b/packages/coding-agent/examples/sdk/04-skills.ts
@@ -5,7 +5,7 @@
* Discover, filter, merge, or replace them.
*/
-import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverSkills, SessionManager, type Skill } from "@oh-my-pi/pi-coding-agent";
// Discover all skills from cwd/.pi/skills, ~/.pi/agent/skills, etc.
const allSkills = discoverSkills();
diff --git a/packages/coding-agent/examples/sdk/05-tools.ts b/packages/coding-agent/examples/sdk/05-tools.ts
index 09592cbf5..f64fbdbd8 100644
--- a/packages/coding-agent/examples/sdk/05-tools.ts
+++ b/packages/coding-agent/examples/sdk/05-tools.ts
@@ -20,7 +20,7 @@ import {
readOnlyTools, // read, grep, find, ls - uses process.cwd()
readTool,
SessionManager,
-} from "@mariozechner/pi-coding-agent";
+} from "@oh-my-pi/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Read-only mode (no edit/write) - uses process.cwd()
diff --git a/packages/coding-agent/examples/sdk/06-hooks.ts b/packages/coding-agent/examples/sdk/06-hooks.ts
index d0a7a07f5..108cc3fff 100644
--- a/packages/coding-agent/examples/sdk/06-hooks.ts
+++ b/packages/coding-agent/examples/sdk/06-hooks.ts
@@ -4,7 +4,7 @@
* Hooks intercept agent events for logging, blocking, or modification.
*/
-import { createAgentSession, type HookFactory, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, type HookFactory, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Logging hook
const loggingHook: HookFactory = (api) => {
diff --git a/packages/coding-agent/examples/sdk/07-context-files.ts b/packages/coding-agent/examples/sdk/07-context-files.ts
index 61aa871a1..0bf012963 100644
--- a/packages/coding-agent/examples/sdk/07-context-files.ts
+++ b/packages/coding-agent/examples/sdk/07-context-files.ts
@@ -4,7 +4,7 @@
* Context files provide project-specific instructions loaded into the system prompt.
*/
-import { createAgentSession, discoverContextFiles, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, discoverContextFiles, SessionManager } from "@oh-my-pi/pi-coding-agent";
// Discover AGENTS.md files walking up from cwd
const discovered = discoverContextFiles();
diff --git a/packages/coding-agent/examples/sdk/08-slash-commands.ts b/packages/coding-agent/examples/sdk/08-slash-commands.ts
index 8c8dc08bf..0394f8e43 100644
--- a/packages/coding-agent/examples/sdk/08-slash-commands.ts
+++ b/packages/coding-agent/examples/sdk/08-slash-commands.ts
@@ -9,7 +9,7 @@ import {
discoverSlashCommands,
type FileSlashCommand,
SessionManager,
-} from "@mariozechner/pi-coding-agent";
+} from "@oh-my-pi/pi-coding-agent";
// Discover commands from cwd/.pi/commands/ and ~/.pi/agent/commands/
const discovered = discoverSlashCommands();
diff --git a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts
index 22cbf98bd..949e8c5a4 100644
--- a/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts
+++ b/packages/coding-agent/examples/sdk/09-api-keys-and-oauth.ts
@@ -11,7 +11,7 @@ import {
discoverModels,
ModelRegistry,
SessionManager,
-} from "@mariozechner/pi-coding-agent";
+} from "@oh-my-pi/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.pi/agent/auth.json
// discoverModels() loads built-in + custom models from ~/.pi/agent/models.json
diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts
index a5451e2ed..4ad32a1cb 100644
--- a/packages/coding-agent/examples/sdk/10-settings.ts
+++ b/packages/coding-agent/examples/sdk/10-settings.ts
@@ -4,7 +4,7 @@
* Override settings using SettingsManager.
*/
-import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "@oh-my-pi/pi-coding-agent";
// Load current settings (merged global + project)
const settings = loadSettings();
diff --git a/packages/coding-agent/examples/sdk/11-sessions.ts b/packages/coding-agent/examples/sdk/11-sessions.ts
index f1bbd0476..e22f53ba3 100644
--- a/packages/coding-agent/examples/sdk/11-sessions.ts
+++ b/packages/coding-agent/examples/sdk/11-sessions.ts
@@ -4,7 +4,7 @@
* Control session persistence: in-memory, new file, continue, or open specific.
*/
-import { createAgentSession, SessionManager } from "@mariozechner/pi-coding-agent";
+import { createAgentSession, SessionManager } from "@oh-my-pi/pi-coding-agent";
// In-memory (no persistence)
const { session: inMemory } = await createAgentSession({
diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts
index 8ae7f5a4c..e940fb3de 100644
--- a/packages/coding-agent/examples/sdk/12-full-control.ts
+++ b/packages/coding-agent/examples/sdk/12-full-control.ts
@@ -8,7 +8,7 @@
* paths relative to your cwd.
*/
-import { getModel } from "@mariozechner/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import {
AuthStorage,
type CustomTool,
@@ -19,7 +19,7 @@ import {
ModelRegistry,
SessionManager,
SettingsManager,
-} from "@mariozechner/pi-coding-agent";
+} from "@oh-my-pi/pi-coding-agent";
import { Type } from "@sinclair/typebox";
// Custom auth storage location
diff --git a/packages/coding-agent/examples/sdk/README.md b/packages/coding-agent/examples/sdk/README.md
index ada778364..f7cc681c6 100644
--- a/packages/coding-agent/examples/sdk/README.md
+++ b/packages/coding-agent/examples/sdk/README.md
@@ -4,20 +4,20 @@ Programmatic usage of pi-coding-agent via `createAgentSession()`.
## Examples
-| File | Description |
-|------|-------------|
-| `01-minimal.ts` | Simplest usage with all defaults |
-| `02-custom-model.ts` | Select model and thinking level |
-| `03-custom-prompt.ts` | Replace or modify system prompt |
-| `04-skills.ts` | Discover, filter, or replace skills |
-| `05-tools.ts` | Built-in tools, custom tools |
-| `06-hooks.ts` | Logging, blocking, result modification |
-| `07-context-files.ts` | AGENTS.md context files |
-| `08-slash-commands.ts` | File-based slash commands |
-| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config |
-| `10-settings.ts` | Override compaction, retry, terminal settings |
-| `11-sessions.ts` | In-memory, persistent, continue, list sessions |
-| `12-full-control.ts` | Replace everything, no discovery |
+| File | Description |
+| -------------------------- | ---------------------------------------------- |
+| `01-minimal.ts` | Simplest usage with all defaults |
+| `02-custom-model.ts` | Select model and thinking level |
+| `03-custom-prompt.ts` | Replace or modify system prompt |
+| `04-skills.ts` | Discover, filter, or replace skills |
+| `05-tools.ts` | Built-in tools, custom tools |
+| `06-hooks.ts` | Logging, blocking, result modification |
+| `07-context-files.ts` | AGENTS.md context files |
+| `08-slash-commands.ts` | File-based slash commands |
+| `09-api-keys-and-oauth.ts` | API key resolution, OAuth config |
+| `10-settings.ts` | Override compaction, retry, terminal settings |
+| `11-sessions.ts` | In-memory, persistent, continue, list sessions |
+| `12-full-control.ts` | Replace everything, no discovery |
## Running
@@ -29,25 +29,28 @@ npx tsx examples/sdk/01-minimal.ts
## Quick Reference
```typescript
-import { getModel } from "@mariozechner/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import {
- AuthStorage,
- createAgentSession,
- discoverAuthStorage,
- discoverModels,
- discoverSkills,
- discoverHooks,
- discoverCustomTools,
- discoverContextFiles,
- discoverSlashCommands,
- loadSettings,
- buildSystemPrompt,
- ModelRegistry,
- SessionManager,
- codingTools,
- readOnlyTools,
- readTool, bashTool, editTool, writeTool,
-} from "@mariozechner/pi-coding-agent";
+ AuthStorage,
+ createAgentSession,
+ discoverAuthStorage,
+ discoverModels,
+ discoverSkills,
+ discoverHooks,
+ discoverCustomTools,
+ discoverContextFiles,
+ discoverSlashCommands,
+ loadSettings,
+ buildSystemPrompt,
+ ModelRegistry,
+ SessionManager,
+ codingTools,
+ readOnlyTools,
+ readTool,
+ bashTool,
+ editTool,
+ writeTool,
+} from "@oh-my-pi/pi-coding-agent";
// Auth and models setup
const authStorage = discoverAuthStorage();
@@ -62,9 +65,9 @@ const { session } = await createAgentSession({ model, thinkingLevel: "high", aut
// Modify prompt
const { session } = await createAgentSession({
- systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
- authStorage,
- modelRegistry,
+ systemPrompt: (defaultPrompt) => defaultPrompt + "\n\nBe concise.",
+ authStorage,
+ modelRegistry,
});
// Read-only
@@ -72,9 +75,9 @@ const { session } = await createAgentSession({ tools: readOnlyTools, authStorage
// In-memory
const { session } = await createAgentSession({
- sessionManager: SessionManager.inMemory(),
- authStorage,
- modelRegistry,
+ sessionManager: SessionManager.inMemory(),
+ authStorage,
+ modelRegistry,
});
// Full control
@@ -83,69 +86,69 @@ customAuth.setRuntimeApiKey("anthropic", process.env.MY_KEY!);
const customRegistry = new ModelRegistry(customAuth);
const { session } = await createAgentSession({
- model,
- authStorage: customAuth,
- modelRegistry: customRegistry,
- systemPrompt: "You are helpful.",
- tools: [readTool, bashTool],
- customTools: [{ tool: myTool }],
- hooks: [{ factory: myHook }],
- skills: [],
- contextFiles: [],
- slashCommands: [],
- sessionManager: SessionManager.inMemory(),
+ model,
+ authStorage: customAuth,
+ modelRegistry: customRegistry,
+ systemPrompt: "You are helpful.",
+ tools: [readTool, bashTool],
+ customTools: [{ tool: myTool }],
+ hooks: [{ factory: myHook }],
+ skills: [],
+ contextFiles: [],
+ slashCommands: [],
+ sessionManager: SessionManager.inMemory(),
});
// Run prompts
session.subscribe((event) => {
- if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
+ if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
});
await session.prompt("Hello");
```
## Options
-| Option | Default | Description |
-|--------|---------|-------------|
-| `authStorage` | `discoverAuthStorage()` | Credential storage |
-| `modelRegistry` | `discoverModels(authStorage)` | Model registry |
-| `cwd` | `process.cwd()` | Working directory |
-| `agentDir` | `~/.pi/agent` | Config directory |
-| `model` | From settings/first available | Model to use |
-| `thinkingLevel` | From settings/"off" | off, low, medium, high |
-| `systemPrompt` | Discovered | String or `(default) => modified` |
-| `tools` | `codingTools` | Built-in tools |
-| `customTools` | Discovered | Replaces discovery |
-| `additionalCustomToolPaths` | `[]` | Merge with discovery |
-| `hooks` | Discovered | Replaces discovery |
-| `additionalHookPaths` | `[]` | Merge with discovery |
-| `skills` | Discovered | Skills for prompt |
-| `contextFiles` | Discovered | AGENTS.md files |
-| `slashCommands` | Discovered | File commands |
-| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
-| `settingsManager` | From agentDir | Settings overrides |
+| Option | Default | Description |
+| --------------------------- | ----------------------------- | --------------------------------- |
+| `authStorage` | `discoverAuthStorage()` | Credential storage |
+| `modelRegistry` | `discoverModels(authStorage)` | Model registry |
+| `cwd` | `process.cwd()` | Working directory |
+| `agentDir` | `~/.pi/agent` | Config directory |
+| `model` | From settings/first available | Model to use |
+| `thinkingLevel` | From settings/"off" | off, low, medium, high |
+| `systemPrompt` | Discovered | String or `(default) => modified` |
+| `tools` | `codingTools` | Built-in tools |
+| `customTools` | Discovered | Replaces discovery |
+| `additionalCustomToolPaths` | `[]` | Merge with discovery |
+| `hooks` | Discovered | Replaces discovery |
+| `additionalHookPaths` | `[]` | Merge with discovery |
+| `skills` | Discovered | Skills for prompt |
+| `contextFiles` | Discovered | AGENTS.md files |
+| `slashCommands` | Discovered | File commands |
+| `sessionManager` | `SessionManager.create(cwd)` | Persistence |
+| `settingsManager` | From agentDir | Settings overrides |
## Events
```typescript
session.subscribe((event) => {
- switch (event.type) {
- case "message_update":
- if (event.assistantMessageEvent.type === "text_delta") {
- process.stdout.write(event.assistantMessageEvent.delta);
- }
- break;
- case "tool_execution_start":
- console.log(`Tool: ${event.toolName}`);
- break;
- case "tool_execution_end":
- console.log(`Result: ${event.result}`);
- break;
- case "agent_end":
- console.log("Done");
- break;
- }
+ switch (event.type) {
+ case "message_update":
+ if (event.assistantMessageEvent.type === "text_delta") {
+ process.stdout.write(event.assistantMessageEvent.delta);
+ }
+ break;
+ case "tool_execution_start":
+ console.log(`Tool: ${event.toolName}`);
+ break;
+ case "tool_execution_end":
+ console.log(`Result: ${event.result}`);
+ break;
+ case "agent_end":
+ console.log("Done");
+ break;
+ }
});
```
diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json
index 2dfcec1b8..8eab34e0a 100644
--- a/packages/coding-agent/package.json
+++ b/packages/coding-agent/package.json
@@ -1,5 +1,5 @@
{
- "name": "@mariozechner/pi-coding-agent",
+ "name": "@oh-my-pi/pi-coding-agent",
"version": "1.337.0",
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
"type": "module",
@@ -38,9 +38,11 @@
"prepublishOnly": "npm run clean && npm run build"
},
"dependencies": {
- "@mariozechner/pi-agent-core": "workspace:*",
- "@mariozechner/pi-ai": "workspace:*",
- "@mariozechner/pi-tui": "workspace:*",
+ "@oh-my-pi/pi-agent-core": "workspace:*",
+ "@oh-my-pi/pi-ai": "workspace:*",
+ "@oh-my-pi/pi-tui": "workspace:*",
+ "@sinclair/typebox": "^0.34.46",
+ "ajv": "^8.17.1",
"chalk": "^5.5.0",
"node-html-parser": "^6.1.13",
"cli-highlight": "^2.1.11",
@@ -69,7 +71,7 @@
"license": "MIT",
"repository": {
"type": "git",
- "url": "git+https://github.com/badlogic/pi-mono.git",
+ "url": "git+https://github.com/can1357/oh-my-pi.git",
"directory": "packages/coding-agent"
},
"engines": {
diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts
index 7094ab54e..c0b85ecd8 100644
--- a/packages/coding-agent/src/cli/args.ts
+++ b/packages/coding-agent/src/cli/args.ts
@@ -2,7 +2,7 @@
* CLI argument parsing and help display
*/
-import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
+import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import chalk from "chalk";
import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR } from "../config.js";
import { allTools, type ToolName } from "../core/tools/index.js";
diff --git a/packages/coding-agent/src/cli/file-processor.ts b/packages/coding-agent/src/cli/file-processor.ts
index 7f82d796d..fab62ff99 100644
--- a/packages/coding-agent/src/cli/file-processor.ts
+++ b/packages/coding-agent/src/cli/file-processor.ts
@@ -3,7 +3,7 @@
*/
import { access, readFile, stat } from "node:fs/promises";
-import type { ImageContent } from "@mariozechner/pi-ai";
+import type { ImageContent } from "@oh-my-pi/pi-ai";
import chalk from "chalk";
import { resolve } from "path";
import { resolveReadPath } from "../core/tools/path-utils.js";
diff --git a/packages/coding-agent/src/cli/list-models.ts b/packages/coding-agent/src/cli/list-models.ts
index 4e0700044..b2bd50a74 100644
--- a/packages/coding-agent/src/cli/list-models.ts
+++ b/packages/coding-agent/src/cli/list-models.ts
@@ -2,7 +2,7 @@
* List available models with optional fuzzy search
*/
-import type { Api, Model } from "@mariozechner/pi-ai";
+import type { Api, Model } from "@oh-my-pi/pi-ai";
import type { ModelRegistry } from "../core/model-registry.js";
import { fuzzyFilter } from "../utils/fuzzy.js";
diff --git a/packages/coding-agent/src/cli/session-picker.ts b/packages/coding-agent/src/cli/session-picker.ts
index f1ff837b5..27cf2cb65 100644
--- a/packages/coding-agent/src/cli/session-picker.ts
+++ b/packages/coding-agent/src/cli/session-picker.ts
@@ -2,7 +2,7 @@
* TUI session selector for --resume flag
*/
-import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
+import { ProcessTerminal, TUI } from "@oh-my-pi/pi-tui";
import type { SessionInfo } from "../core/session-manager.js";
import { SessionSelectorComponent } from "../modes/interactive/components/session-selector.js";
diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts
index 9a4d752cc..b64dd6c2f 100644
--- a/packages/coding-agent/src/core/agent-session.ts
+++ b/packages/coding-agent/src/core/agent-session.ts
@@ -13,9 +13,9 @@
* Modes use this class and add their own I/O layer on top.
*/
-import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@mariozechner/pi-agent-core";
-import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@mariozechner/pi-ai";
-import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai";
+import type { Agent, AgentEvent, AgentMessage, AgentState, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import type { AssistantMessage, ImageContent, Message, Model, TextContent } from "@oh-my-pi/pi-ai";
+import { isContextOverflow, modelsAreEqual, supportsXhigh } from "@oh-my-pi/pi-ai";
import { getAuthPath } from "../config.js";
import { type BashResult, executeBash as executeBashCommand } from "./bash-executor.js";
import {
@@ -1162,7 +1162,9 @@ export class AgentSession {
if (reason === "overflow") {
throw new Error(
- `Context overflow: ${error instanceof Error ? error.message : "compaction failed"}. Your input may be too large for the context window.`,
+ `Context overflow: ${
+ error instanceof Error ? error.message : "compaction failed"
+ }. Your input may be too large for the context window.`,
);
}
} finally {
diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts
index 8e5e7b392..8cfc512c4 100644
--- a/packages/coding-agent/src/core/auth-storage.ts
+++ b/packages/coding-agent/src/core/auth-storage.ts
@@ -14,7 +14,7 @@ import {
loginGitHubCopilot,
type OAuthCredentials,
type OAuthProvider,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
export type ApiKeyCredential = {
type: "api_key";
diff --git a/packages/coding-agent/src/core/compaction/branch-summarization.ts b/packages/coding-agent/src/core/compaction/branch-summarization.ts
index d3b4f59da..c219bc9a0 100644
--- a/packages/coding-agent/src/core/compaction/branch-summarization.ts
+++ b/packages/coding-agent/src/core/compaction/branch-summarization.ts
@@ -5,9 +5,9 @@
* a summary of the branch being left so context isn't lost.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { Model } from "@mariozechner/pi-ai";
-import { completeSimple } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { Model } from "@oh-my-pi/pi-ai";
+import { completeSimple } from "@oh-my-pi/pi-ai";
import {
convertToLlm,
createBranchSummaryMessage,
diff --git a/packages/coding-agent/src/core/compaction/compaction.ts b/packages/coding-agent/src/core/compaction/compaction.ts
index d46bdfb7b..e9844c258 100644
--- a/packages/coding-agent/src/core/compaction/compaction.ts
+++ b/packages/coding-agent/src/core/compaction/compaction.ts
@@ -5,9 +5,9 @@
* and after compaction the session is reloaded.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { AssistantMessage, Model, Usage } from "@mariozechner/pi-ai";
-import { complete, completeSimple } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { AssistantMessage, Model, Usage } from "@oh-my-pi/pi-ai";
+import { complete, completeSimple } from "@oh-my-pi/pi-ai";
import { convertToLlm, createBranchSummaryMessage, createHookMessage } from "../messages.js";
import type { CompactionEntry, SessionEntry } from "../session-manager.js";
import {
diff --git a/packages/coding-agent/src/core/compaction/utils.ts b/packages/coding-agent/src/core/compaction/utils.ts
index 9c8f46bca..8651d3d2e 100644
--- a/packages/coding-agent/src/core/compaction/utils.ts
+++ b/packages/coding-agent/src/core/compaction/utils.ts
@@ -2,8 +2,8 @@
* Shared utilities for compaction and branch summarization.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { Message } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { Message } from "@oh-my-pi/pi-ai";
// ============================================================================
// File Operation Tracking
diff --git a/packages/coding-agent/src/core/custom-tools/types.ts b/packages/coding-agent/src/core/custom-tools/types.ts
index ead82bf1b..8cbd90de7 100644
--- a/packages/coding-agent/src/core/custom-tools/types.ts
+++ b/packages/coding-agent/src/core/custom-tools/types.ts
@@ -5,9 +5,9 @@
* They can provide custom rendering for tool calls and results in the TUI.
*/
-import type { AgentToolResult, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
-import type { Model } from "@mariozechner/pi-ai";
-import type { Component } from "@mariozechner/pi-tui";
+import type { AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
+import type { Model } from "@oh-my-pi/pi-ai";
+import type { Component } from "@oh-my-pi/pi-tui";
import type { Static, TSchema } from "@sinclair/typebox";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { ExecOptions, ExecResult } from "../exec.js";
diff --git a/packages/coding-agent/src/core/custom-tools/wrapper.ts b/packages/coding-agent/src/core/custom-tools/wrapper.ts
index 3faa1e1a5..546eef0e7 100644
--- a/packages/coding-agent/src/core/custom-tools/wrapper.ts
+++ b/packages/coding-agent/src/core/custom-tools/wrapper.ts
@@ -2,7 +2,7 @@
* Wraps CustomTool instances into AgentTool for use with the agent.
*/
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { CustomTool, CustomToolContext, LoadedCustomTool } from "./types.js";
/**
diff --git a/packages/coding-agent/src/core/export-html/index.ts b/packages/coding-agent/src/core/export-html/index.ts
index 577f262c3..aee7a4413 100644
--- a/packages/coding-agent/src/core/export-html/index.ts
+++ b/packages/coding-agent/src/core/export-html/index.ts
@@ -1,4 +1,4 @@
-import type { AgentState } from "@mariozechner/pi-agent-core";
+import type { AgentState } from "@oh-my-pi/pi-agent-core";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { basename, join } from "path";
import { APP_NAME, getExportTemplateDir } from "../../config.js";
diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts
index 56bb53a24..a23ac3098 100644
--- a/packages/coding-agent/src/core/hooks/runner.ts
+++ b/packages/coding-agent/src/core/hooks/runner.ts
@@ -2,8 +2,8 @@
* Hook runner - executes hooks and manages their lifecycle.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { Model } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { Model } from "@oh-my-pi/pi-ai";
import { theme } from "../../modes/interactive/theme/theme.js";
import type { ModelRegistry } from "../model-registry.js";
import type { SessionManager } from "../session-manager.js";
@@ -400,7 +400,7 @@ export class HookRunner {
*/
async emitBeforeAgentStart(
prompt: string,
- images?: import("@mariozechner/pi-ai").ImageContent[],
+ images?: import("@oh-my-pi/pi-ai").ImageContent[],
): Promise {
const ctx = this.createContext();
let result: BeforeAgentStartEventResult | undefined;
diff --git a/packages/coding-agent/src/core/hooks/tool-wrapper.ts b/packages/coding-agent/src/core/hooks/tool-wrapper.ts
index 632b9123e..748dc404c 100644
--- a/packages/coding-agent/src/core/hooks/tool-wrapper.ts
+++ b/packages/coding-agent/src/core/hooks/tool-wrapper.ts
@@ -2,7 +2,7 @@
* Tool wrapper - wraps tools with hook callbacks for interception.
*/
-import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
+import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { HookRunner } from "./runner.js";
import type { ToolCallEventResult, ToolResultEventResult } from "./types.js";
diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts
index a96bbf8a9..f34400630 100644
--- a/packages/coding-agent/src/core/hooks/types.ts
+++ b/packages/coding-agent/src/core/hooks/types.ts
@@ -5,9 +5,9 @@
* and interact with the user via UI primitives.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@mariozechner/pi-ai";
-import type { Component, TUI } from "@mariozechner/pi-tui";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, Message, Model, TextContent, ToolResultMessage } from "@oh-my-pi/pi-ai";
+import type { Component, TUI } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { CompactionPreparation, CompactionResult } from "../compaction/index.js";
import type { ExecOptions, ExecResult } from "../exec.js";
diff --git a/packages/coding-agent/src/core/mcp/loader.ts b/packages/coding-agent/src/core/mcp/loader.ts
index 2bed67f8a..2288e327b 100644
--- a/packages/coding-agent/src/core/mcp/loader.ts
+++ b/packages/coding-agent/src/core/mcp/loader.ts
@@ -4,10 +4,8 @@
* Integrates MCP tool discovery with the custom tools system.
*/
-import type { TSchema } from "@sinclair/typebox";
import type { LoadedCustomTool } from "../custom-tools/types.js";
-import { createMCPManager, type MCPLoadResult, MCPManager } from "./manager.js";
-import type { MCPToolDetails } from "./tool-bridge.js";
+import { type MCPLoadResult, MCPManager } from "./manager.js";
/** Result from loading MCP tools */
export interface MCPToolsLoadResult {
diff --git a/packages/coding-agent/src/core/mcp/tool-bridge.ts b/packages/coding-agent/src/core/mcp/tool-bridge.ts
index f63b2f9c4..3cbc01ce7 100644
--- a/packages/coding-agent/src/core/mcp/tool-bridge.ts
+++ b/packages/coding-agent/src/core/mcp/tool-bridge.ts
@@ -7,7 +7,7 @@
import type { TSchema } from "@sinclair/typebox";
import type { CustomTool, CustomToolResult } from "../custom-tools/types.js";
import { callTool } from "./client.js";
-import type { MCPContent, MCPServerConnection, MCPToolDefinition, MCPToolWithServer } from "./types.js";
+import type { MCPContent, MCPServerConnection, MCPToolDefinition } from "./types.js";
/** Details included in MCP tool results for rendering */
export interface MCPToolDetails {
diff --git a/packages/coding-agent/src/core/mcp/transports/stdio.ts b/packages/coding-agent/src/core/mcp/transports/stdio.ts
index cd94a7ef9..6ce07a7f1 100644
--- a/packages/coding-agent/src/core/mcp/transports/stdio.ts
+++ b/packages/coding-agent/src/core/mcp/transports/stdio.ts
@@ -187,7 +187,7 @@ export class StdioTransport implements MCPTransport {
reject,
});
- const message = JSON.stringify(request) + "\n";
+ const message = `${JSON.stringify(request)}\n`;
try {
// Bun's FileSink has write() method directly
this.process!.stdin.write(message);
@@ -210,7 +210,7 @@ export class StdioTransport implements MCPTransport {
params: params ?? {},
};
- const message = JSON.stringify(notification) + "\n";
+ const message = `${JSON.stringify(notification)}\n`;
// Bun's FileSink has write() method directly
this.process.stdin.write(message);
this.process.stdin.flush();
diff --git a/packages/coding-agent/src/core/messages.ts b/packages/coding-agent/src/core/messages.ts
index 2726903d5..8b5b28cc1 100644
--- a/packages/coding-agent/src/core/messages.ts
+++ b/packages/coding-agent/src/core/messages.ts
@@ -5,8 +5,8 @@
* and provides a transformer to convert them to LLM-compatible messages.
*/
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
export const COMPACTION_SUMMARY_PREFIX = `The conversation history before this point was compacted into the following summary:
@@ -65,7 +65,7 @@ export interface CompactionSummaryMessage {
}
// Extend CustomAgentMessages via declaration merging
-declare module "@mariozechner/pi-agent-core" {
+declare module "@oh-my-pi/pi-agent-core" {
interface CustomAgentMessages {
bashExecution: BashExecutionMessage;
hookMessage: HookMessage;
diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts
index 94a839f8d..deb758e08 100644
--- a/packages/coding-agent/src/core/model-registry.ts
+++ b/packages/coding-agent/src/core/model-registry.ts
@@ -10,7 +10,7 @@ import {
type KnownProvider,
type Model,
normalizeDomain,
-} from "@mariozechner/pi-ai";
+} from "@oh-my-pi/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { existsSync, readFileSync } from "fs";
@@ -196,7 +196,9 @@ export class ModelRegistry {
}
return {
models: [],
- error: `Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
+ error: `Failed to load models.json: ${
+ error instanceof Error ? error.message : error
+ }\n\nFile: ${modelsJsonPath}`,
};
}
}
diff --git a/packages/coding-agent/src/core/model-resolver.ts b/packages/coding-agent/src/core/model-resolver.ts
index d21244135..bab5a004e 100644
--- a/packages/coding-agent/src/core/model-resolver.ts
+++ b/packages/coding-agent/src/core/model-resolver.ts
@@ -2,8 +2,8 @@
* Model resolution, scoping, and initial selection
*/
-import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
-import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@mariozechner/pi-ai";
+import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import { type Api, type KnownProvider, type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
import chalk from "chalk";
import { minimatch } from "minimatch";
import { isValidThinkingLevel } from "../cli/args.js";
diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts
index 3b9202ea9..a0e45b834 100644
--- a/packages/coding-agent/src/core/sdk.ts
+++ b/packages/coding-agent/src/core/sdk.ts
@@ -29,8 +29,8 @@
* ```
*/
-import { Agent, type ThinkingLevel } from "@mariozechner/pi-agent-core";
-import type { Model } from "@mariozechner/pi-ai";
+import { Agent, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import type { Model } from "@oh-my-pi/pi-ai";
import { join } from "path";
import { getAgentDir } from "../config.js";
import { AgentSession } from "./agent-session.js";
@@ -432,7 +432,7 @@ function createLoadedHooksFromDefinitions(definitions: Array<{ path?: string; fa
* const { session } = await createAgentSession();
*
* // With explicit model
- * import { getModel } from '@mariozechner/pi-ai';
+ * import { getModel } from '@oh-my-pi/pi-ai';
* const { session } = await createAgentSession({
* model: getModel('anthropic', 'claude-opus-4-5'),
* thinkingLevel: 'high',
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 2a35e0886..9e45c7219 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -1,5 +1,5 @@
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { ImageContent, Message, TextContent } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, Message, TextContent } from "@oh-my-pi/pi-ai";
import {
appendFileSync,
closeSync,
diff --git a/packages/coding-agent/src/core/tools/ask.ts b/packages/coding-agent/src/core/tools/ask.ts
index c721a0ad7..916a73ba3 100644
--- a/packages/coding-agent/src/core/tools/ask.ts
+++ b/packages/coding-agent/src/core/tools/ask.ts
@@ -15,7 +15,7 @@
* and add "(Recommended)" at the end of the label
*/
-import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@mariozechner/pi-agent-core";
+import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
// =============================================================================
diff --git a/packages/coding-agent/src/core/tools/ast.ts b/packages/coding-agent/src/core/tools/ast.ts
index 5483a12a5..91fad9cb9 100644
--- a/packages/coding-agent/src/core/tools/ast.ts
+++ b/packages/coding-agent/src/core/tools/ast.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { Subprocess } from "bun";
import { ensureTool } from "../../utils/tools-manager.js";
@@ -218,10 +218,14 @@ Output truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.
// Format output based on action
let formattedOutput: string;
if (action === "apply") {
- formattedOutput = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n`;
+ formattedOutput = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${
+ fileCount !== 1 ? "s" : ""
+ }:\n`;
formattedOutput += Array.from(files).join("\n");
} else if (action === "preview") {
- formattedOutput = `Preview of ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n\n`;
+ formattedOutput = `Preview of ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${
+ fileCount !== 1 ? "s" : ""
+ }:\n\n`;
for (const m of matches) {
formattedOutput += `${m.file}:${m.line}\n`;
formattedOutput += ` - ${m.text}\n`;
@@ -232,7 +236,9 @@ Output truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.
}
} else {
// search mode
- formattedOutput = `Found ${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}:\n\n`;
+ formattedOutput = `Found ${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${
+ fileCount !== 1 ? "s" : ""
+ }:\n\n`;
for (const m of matches) {
formattedOutput += `${m.file}:${m.line}: ${m.text}\n`;
}
@@ -255,7 +261,9 @@ Output truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB.
if (truncation.truncatedBy === "lines") {
finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}]`;
} else {
- finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit)]`;
+ finalOutput += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(
+ DEFAULT_MAX_BYTES,
+ )} limit)]`;
}
}
diff --git a/packages/coding-agent/src/core/tools/bash.ts b/packages/coding-agent/src/core/tools/bash.ts
index 01b15c32d..39907fe6f 100644
--- a/packages/coding-agent/src/core/tools/bash.ts
+++ b/packages/coding-agent/src/core/tools/bash.ts
@@ -1,7 +1,7 @@
import { createWriteStream } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { Subprocess } from "bun";
import { getShellConfig, killProcessTree } from "../../utils/shell.js";
@@ -30,7 +30,9 @@ export function createBashTool(cwd: string): AgentTool {
return {
name: "bash",
label: "bash",
- description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
+ description: `Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${
+ DEFAULT_MAX_BYTES / 1024
+ }KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
parameters: bashSchema,
execute: async (
_toolCallId: string,
@@ -187,11 +189,15 @@ export function createBashTool(cwd: string): AgentTool {
if (truncation.lastLinePartial) {
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split("\n").pop() || "", "utf-8"));
- outputText += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
+ outputText += `\n\n[Showing last ${formatSize(
+ truncation.outputBytes,
+ )} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
} else if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
} else {
- outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
+ outputText += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(
+ DEFAULT_MAX_BYTES,
+ )} limit). Full output: ${tempFilePath}]`;
}
}
diff --git a/packages/coding-agent/src/core/tools/context.ts b/packages/coding-agent/src/core/tools/context.ts
index a08dfb4b4..894d4c757 100644
--- a/packages/coding-agent/src/core/tools/context.ts
+++ b/packages/coding-agent/src/core/tools/context.ts
@@ -1,8 +1,8 @@
-import type { AgentToolContext } from "@mariozechner/pi-agent-core";
+import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import type { CustomToolContext } from "../custom-tools/types.js";
import type { HookUIContext } from "../hooks/types.js";
-declare module "@mariozechner/pi-agent-core" {
+declare module "@oh-my-pi/pi-agent-core" {
interface AgentToolContext extends CustomToolContext {
ui?: HookUIContext;
hasUI?: boolean;
diff --git a/packages/coding-agent/src/core/tools/edit.ts b/packages/coding-agent/src/core/tools/edit.ts
index 3360308c4..df7f8242a 100644
--- a/packages/coding-agent/src/core/tools/edit.ts
+++ b/packages/coding-agent/src/core/tools/edit.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { constants } from "fs";
import { access, readFile, writeFile } from "fs/promises";
diff --git a/packages/coding-agent/src/core/tools/exa/render.ts b/packages/coding-agent/src/core/tools/exa/render.ts
index beb5a15bd..5d5861d70 100644
--- a/packages/coding-agent/src/core/tools/exa/render.ts
+++ b/packages/coding-agent/src/core/tools/exa/render.ts
@@ -4,8 +4,8 @@
* Tree-based rendering with collapsed/expanded states for Exa search results.
*/
-import type { Component } from "@mariozechner/pi-tui";
-import { Text } from "@mariozechner/pi-tui";
+import type { Component } from "@oh-my-pi/pi-tui";
+import { Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import type { RenderResultOptions } from "../../custom-tools/types.js";
import { logViewError } from "./logger.js";
@@ -81,7 +81,10 @@ export function renderExaResult(
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
const toolLabel = details?.toolName ?? "Exa Search";
- let headerParts = `${icon} ${theme.fg("toolTitle", toolLabel)} · ${theme.fg("dim", `${resultCount} result${resultCount !== 1 ? "s" : ""}`)}`;
+ let headerParts = `${icon} ${theme.fg("toolTitle", toolLabel)} · ${theme.fg(
+ "dim",
+ `${resultCount} result${resultCount !== 1 ? "s" : ""}`,
+ )}`;
if (cost !== undefined) {
headerParts += ` · ${theme.fg("muted", `$${cost.toFixed(4)}`)}`;
@@ -109,7 +112,10 @@ export function renderExaResult(
}
if (resultCount > 1) {
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`)}`;
+ text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg(
+ "muted",
+ `${resultCount - 1} more result${resultCount !== 2 ? "s" : ""}`,
+ )}`;
}
}
} else {
@@ -129,7 +135,10 @@ export function renderExaResult(
const domain = res.url ? getDomain(res.url) : "";
const domainPart = domain ? theme.fg("dim", ` (${domain})`) : "";
- text += `\n ${theme.fg("dim", TREE_SPACE)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)}${domainPart}`;
+ text += `\n ${theme.fg("dim", TREE_SPACE)} ${theme.fg("dim", branch)} ${theme.fg(
+ "accent",
+ title,
+ )}${domainPart}`;
// URL
if (res.url) {
diff --git a/packages/coding-agent/src/core/tools/find.ts b/packages/coding-agent/src/core/tools/find.ts
index bc0ed9930..db3fa1bc9 100644
--- a/packages/coding-agent/src/core/tools/find.ts
+++ b/packages/coding-agent/src/core/tools/find.ts
@@ -1,6 +1,6 @@
import { existsSync, type Stats, statSync } from "node:fs";
import path from "node:path";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { globSync } from "glob";
import { ensureTool } from "../../utils/tools-manager.js";
@@ -41,7 +41,9 @@ export function createFindTool(cwd: string): AgentTool {
return {
name: "find",
label: "find",
- description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
+ description: `Search for files by glob pattern. Returns matching file paths relative to the search directory. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} results or ${
+ DEFAULT_MAX_BYTES / 1024
+ }KB (whichever is hit first).`,
parameters: findSchema,
execute: async (
_toolCallId: string,
diff --git a/packages/coding-agent/src/core/tools/grep.ts b/packages/coding-agent/src/core/tools/grep.ts
index 973071212..0a9e164c2 100644
--- a/packages/coding-agent/src/core/tools/grep.ts
+++ b/packages/coding-agent/src/core/tools/grep.ts
@@ -1,6 +1,6 @@
import { readFileSync, type Stats, statSync } from "node:fs";
import nodePath from "node:path";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { Subprocess } from "bun";
import { ensureTool } from "../../utils/tools-manager.js";
@@ -64,7 +64,9 @@ export function createGrepTool(cwd: string): AgentTool {
return {
name: "grep",
label: "grep",
- description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
+ description: `Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to ${DEFAULT_LIMIT} matches or ${
+ DEFAULT_MAX_BYTES / 1024
+ }KB (whichever is hit first). Long lines are truncated to ${GREP_MAX_LINE_LENGTH} chars.`,
parameters: grepSchema,
execute: async (
_toolCallId: string,
diff --git a/packages/coding-agent/src/core/tools/index.ts b/packages/coding-agent/src/core/tools/index.ts
index b1233a27b..1fc6cf55f 100644
--- a/packages/coding-agent/src/core/tools/index.ts
+++ b/packages/coding-agent/src/core/tools/index.ts
@@ -24,7 +24,7 @@ export {
} from "./web-search/index.js";
export { createWriteTool, writeTool } from "./write.js";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { askTool, createAskTool } from "./ask.js";
import { astTool, createAstTool } from "./ast.js";
import { bashTool, createBashTool } from "./bash.js";
diff --git a/packages/coding-agent/src/core/tools/ls.ts b/packages/coding-agent/src/core/tools/ls.ts
index ca27bfe4a..572f15ff1 100644
--- a/packages/coding-agent/src/core/tools/ls.ts
+++ b/packages/coding-agent/src/core/tools/ls.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { existsSync, readdirSync, statSync } from "fs";
import nodePath from "path";
@@ -21,7 +21,9 @@ export function createLsTool(cwd: string): AgentTool {
return {
name: "ls",
label: "ls",
- description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first).`,
+ description: `List directory contents. Returns entries sorted alphabetically, with '/' suffix for directories. Includes dotfiles. Output is truncated to ${DEFAULT_LIMIT} entries or ${
+ DEFAULT_MAX_BYTES / 1024
+ }KB (whichever is hit first).`,
parameters: lsSchema,
execute: async (
_toolCallId: string,
diff --git a/packages/coding-agent/src/core/tools/lsp/index.ts b/packages/coding-agent/src/core/tools/lsp/index.ts
index 75db23f1d..026434f59 100644
--- a/packages/coding-agent/src/core/tools/lsp/index.ts
+++ b/packages/coding-agent/src/core/tools/lsp/index.ts
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import path from "node:path";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import { resolveToCwd } from "../path-utils.js";
import { ensureFileOpen, getOrCreateClient, refreshFile, sendRequest } from "./client.js";
@@ -429,7 +429,9 @@ Rust-analyzer specific (require rust-analyzer):
output = `No symbols matching "${query}"`;
} else {
const lines = result.map((s) => formatSymbolInformation(s, cwd));
- output = `Found ${result.length} symbol(s) matching "${query}":\n${lines.map((l) => ` ${l}`).join("\n")}`;
+ output = `Found ${result.length} symbol(s) matching "${query}":\n${lines
+ .map((l) => ` ${l}`)
+ .join("\n")}`;
}
break;
}
@@ -564,7 +566,9 @@ Rust-analyzer specific (require rust-analyzer):
}
return ` [${i}] ${actionItem.title}`;
});
- output = `Available code actions:\n${lines.join("\n")}\n\nUse action_index parameter to apply a specific action.`;
+ output = `Available code actions:\n${lines.join(
+ "\n",
+ )}\n\nUse action_index parameter to apply a specific action.`;
}
break;
}
diff --git a/packages/coding-agent/src/core/tools/lsp/render.ts b/packages/coding-agent/src/core/tools/lsp/render.ts
index 19f0f5156..c29636005 100644
--- a/packages/coding-agent/src/core/tools/lsp/render.ts
+++ b/packages/coding-agent/src/core/tools/lsp/render.ts
@@ -8,8 +8,8 @@
* - Collapsible/expandable views
*/
-import type { AgentToolResult, RenderResultOptions } from "@mariozechner/pi-agent-core";
-import { Text } from "@mariozechner/pi-tui";
+import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
+import { Text } from "@oh-my-pi/pi-tui";
import { highlight, supportsLanguage } from "cli-highlight";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import type { LspParams, LspToolDetails } from "./types.js";
@@ -273,7 +273,10 @@ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded:
const fileCont = isLastFile ? " " : `${TREE_PIPE} `;
if (locs.length === 1) {
- output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)}:${theme.fg("muted", `${locs[0][0]}:${locs[0][1]}`)}`;
+ output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)}:${theme.fg(
+ "muted",
+ `${locs[0][0]}:${locs[0][1]}`,
+ )}`;
} else {
output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)}`;
@@ -372,7 +375,10 @@ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded
const sym = symbols[i];
const prefix = getPrefix(i);
const branch = isLastSibling(i) ? TREE_END : TREE_MID;
- output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.name)} ${theme.fg("muted", `@${sym.line}`)}`;
+ output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.name)} ${theme.fg(
+ "muted",
+ `@${sym.line}`,
+ )}`;
}
return new Text(output, 0, 0);
}
diff --git a/packages/coding-agent/src/core/tools/notebook.ts b/packages/coding-agent/src/core/tools/notebook.ts
index e9f913b93..48071513d 100644
--- a/packages/coding-agent/src/core/tools/notebook.ts
+++ b/packages/coding-agent/src/core/tools/notebook.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { resolveToCwd } from "./path-utils.js";
diff --git a/packages/coding-agent/src/core/tools/read.ts b/packages/coding-agent/src/core/tools/read.ts
index 46850a02f..5e3df21ab 100644
--- a/packages/coding-agent/src/core/tools/read.ts
+++ b/packages/coding-agent/src/core/tools/read.ts
@@ -1,5 +1,5 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
-import type { ImageContent, TextContent } from "@mariozechner/pi-ai";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
import { Type } from "@sinclair/typebox";
import { spawnSync } from "child_process";
import { constants } from "fs";
@@ -46,7 +46,9 @@ export function createReadTool(cwd: string): AgentTool {
name: "read",
label: "read",
description: `Read the contents of a file. Supports:
-- Text files (truncated to ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB, use offset/limit for large files)
+- Text files (truncated to ${DEFAULT_MAX_LINES} lines or ${
+ DEFAULT_MAX_BYTES / 1024
+ }KB, use offset/limit for large files)
- Images (jpg, png, gif, webp) - sent as attachments
- Documents (pdf, docx, pptx, xlsx, epub, rtf) - converted to markdown via markitdown if available`,
parameters: readSchema,
@@ -113,7 +115,9 @@ export function createReadTool(cwd: string): AgentTool {
let outputText = truncation.content;
if (truncation.truncated) {
- outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(DEFAULT_MAX_BYTES)}]`;
+ outputText += `\n\n[Document converted via markitdown. Output truncated to ${formatSize(
+ DEFAULT_MAX_BYTES,
+ )}]`;
details = { truncation };
}
@@ -160,7 +164,9 @@ export function createReadTool(cwd: string): AgentTool {
if (truncation.firstLineExceedsLimit) {
// First line at offset exceeds 30KB - tell model to use bash
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(
+ DEFAULT_MAX_BYTES,
+ )} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
details = { truncation };
} else if (truncation.truncated) {
// Truncation occurred - build actionable notice
@@ -172,7 +178,9 @@ export function createReadTool(cwd: string): AgentTool {
if (truncation.truncatedBy === "lines") {
outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue]`;
} else {
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue]`;
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(
+ DEFAULT_MAX_BYTES,
+ )} limit). Use offset=${nextOffset} to continue]`;
}
details = { truncation };
} else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
diff --git a/packages/coding-agent/src/core/tools/renderers.ts b/packages/coding-agent/src/core/tools/renderers.ts
index 2ab016b63..99fb16516 100644
--- a/packages/coding-agent/src/core/tools/renderers.ts
+++ b/packages/coding-agent/src/core/tools/renderers.ts
@@ -4,8 +4,8 @@
* These provide rich visualization for tool calls and results in the TUI.
*/
-import type { Component } from "@mariozechner/pi-tui";
-import { Text } from "@mariozechner/pi-tui";
+import type { Component } from "@oh-my-pi/pi-tui";
+import { Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import type { RenderResultOptions } from "../custom-tools/types.js";
import type { AskToolDetails } from "./ask.js";
@@ -217,7 +217,10 @@ const findRenderer: ToolRenderer = {
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
- let text = `${theme.fg("success", ICON_SUCCESS)} ${theme.fg("toolTitle", "find")} ${theme.fg("dim", `${lines.length} file${lines.length !== 1 ? "s" : ""}`)}`;
+ let text = `${theme.fg("success", ICON_SUCCESS)} ${theme.fg("toolTitle", "find")} ${theme.fg(
+ "dim",
+ `${lines.length} file${lines.length !== 1 ? "s" : ""}`,
+ )}`;
for (let i = 0; i < displayLines.length; i++) {
const isLast = i === displayLines.length - 1 && remaining === 0;
const branch = isLast ? TREE_END : TREE_MID;
@@ -414,9 +417,13 @@ const astRenderer: ToolRenderer = {
const icon = mode === "apply" ? theme.fg("success", ICON_SUCCESS) : theme.fg("accent", ICON_INFO);
let summary: string;
if (mode === "apply") {
- summary = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
+ summary = `Applied ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${
+ fileCount !== 1 ? "s" : ""
+ }`;
} else if (mode === "preview") {
- summary = `Preview: ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
+ summary = `Preview: ${matchCount} replacement${matchCount !== 1 ? "s" : ""} in ${fileCount} file${
+ fileCount !== 1 ? "s" : ""
+ }`;
} else {
summary = `${matchCount} match${matchCount !== 1 ? "es" : ""} in ${fileCount} file${fileCount !== 1 ? "s" : ""}`;
}
@@ -636,7 +643,9 @@ const lsRenderer: ToolRenderer = {
const truncated = details?.truncation?.truncated || details?.entryLimitReached;
const icon = truncated ? theme.fg("warning", ICON_WARNING) : theme.fg("success", ICON_SUCCESS);
- let summary = `${dirs.length} dir${dirs.length !== 1 ? "s" : ""}, ${files.length} file${files.length !== 1 ? "s" : ""}`;
+ let summary = `${dirs.length} dir${dirs.length !== 1 ? "s" : ""}, ${files.length} file${
+ files.length !== 1 ? "s" : ""
+ }`;
if (truncated) {
summary += theme.fg("warning", " (truncated)");
}
diff --git a/packages/coding-agent/src/core/tools/replace.ts b/packages/coding-agent/src/core/tools/replace.ts
index d4d8f4dfc..27271403b 100644
--- a/packages/coding-agent/src/core/tools/replace.ts
+++ b/packages/coding-agent/src/core/tools/replace.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import type { Subprocess } from "bun";
import { ensureTool } from "../../utils/tools-manager.js";
diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts
index 01937166e..d556be793 100644
--- a/packages/coding-agent/src/core/tools/task/index.ts
+++ b/packages/coding-agent/src/core/tools/task/index.ts
@@ -13,7 +13,7 @@
* - Session artifacts for debugging
*/
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import { cleanupTempDir, createTempArtifactsDir, getArtifactsDir, writeArtifacts } from "./artifacts.js";
import { discoverAgents, getAgent } from "./discovery.js";
diff --git a/packages/coding-agent/src/core/tools/task/render.ts b/packages/coding-agent/src/core/tools/task/render.ts
index 57ae3d539..a49c43cc5 100644
--- a/packages/coding-agent/src/core/tools/task/render.ts
+++ b/packages/coding-agent/src/core/tools/task/render.ts
@@ -6,8 +6,8 @@
*/
import path from "node:path";
-import type { Component } from "@mariozechner/pi-tui";
-import { Text } from "@mariozechner/pi-tui";
+import type { Component } from "@oh-my-pi/pi-tui";
+import { Text } from "@oh-my-pi/pi-tui";
import type { Theme } from "../../../modes/interactive/theme/theme.js";
import type { RenderResultOptions } from "../../custom-tools/types.js";
import type { AgentProgress, SingleResult, TaskParams, TaskToolDetails } from "./types.js";
diff --git a/packages/coding-agent/src/core/tools/task/types.ts b/packages/coding-agent/src/core/tools/task/types.ts
index b273285ed..43daf65d2 100644
--- a/packages/coding-agent/src/core/tools/task/types.ts
+++ b/packages/coding-agent/src/core/tools/task/types.ts
@@ -1,4 +1,4 @@
-import { StringEnum } from "@mariozechner/pi-ai";
+import { StringEnum } from "@oh-my-pi/pi-ai";
import { type Static, Type } from "@sinclair/typebox";
/** Scope for agent discovery */
diff --git a/packages/coding-agent/src/core/tools/web-fetch.ts b/packages/coding-agent/src/core/tools/web-fetch.ts
index 683f0484b..1000ee62f 100644
--- a/packages/coding-agent/src/core/tools/web-fetch.ts
+++ b/packages/coding-agent/src/core/tools/web-fetch.ts
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { parse as parseHtml } from "node-html-parser";
@@ -1152,7 +1152,9 @@ async function handleStackOverflow(url: string, timeout: number): Promise 0 ? theme.fg("success", "●") : theme.fg("warning", "●");
const expandHint = expanded ? "" : theme.fg("dim", " (Ctrl+O to expand)");
const providerLabel = provider === "anthropic" ? "Anthropic" : "Perplexity";
- let text = `${icon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)} · ${theme.fg("dim", `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`)}${expandHint}`;
+ let text = `${icon} ${theme.fg("toolTitle", "Web Search")} ${theme.fg("dim", `(${providerLabel})`)} · ${theme.fg(
+ "dim",
+ `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`,
+ )}${expandHint}`;
// Get answer text
const contentText = response.answer ?? result.content[0]?.text ?? "";
@@ -107,7 +110,10 @@ export function renderWebSearchResult(
// Show source count summary
if (sourceCount > 0) {
- text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg("muted", `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`)}`;
+ text += `\n ${theme.fg("dim", TREE_END)} ${theme.fg(
+ "muted",
+ `${sourceCount} source${sourceCount !== 1 ? "s" : ""}`,
+ )}`;
}
} else {
// Expanded view: full answer + source tree
@@ -137,8 +143,14 @@ export function renderWebSearchResult(
const age = formatAge(src.ageSeconds) || src.publishedDate;
const agePart = age ? theme.fg("muted", ` · ${age}`) : "";
- text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)} ${theme.fg("dim", `(${domain})`)}${agePart}`;
- text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${cont} ${TREE_HOOK} `)}${theme.fg("mdLinkUrl", src.url)}`;
+ text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", branch)} ${theme.fg("accent", title)} ${theme.fg(
+ "dim",
+ `(${domain})`,
+ )}${agePart}`;
+ text += `\n ${theme.fg("dim", indent)} ${theme.fg("dim", `${cont} ${TREE_HOOK} `)}${theme.fg(
+ "mdLinkUrl",
+ src.url,
+ )}`;
}
}
diff --git a/packages/coding-agent/src/core/tools/write.ts b/packages/coding-agent/src/core/tools/write.ts
index 02317b700..b34545260 100644
--- a/packages/coding-agent/src/core/tools/write.ts
+++ b/packages/coding-agent/src/core/tools/write.ts
@@ -1,4 +1,4 @@
-import type { AgentTool } from "@mariozechner/pi-agent-core";
+import type { AgentTool } from "@oh-my-pi/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { mkdir, writeFile } from "fs/promises";
import { dirname } from "path";
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index 98c8da699..5c4e3e358 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -1,7 +1,7 @@
// Core session management
// Re-export TUI components for custom tool rendering
-export { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+export { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
export {
AgentSession,
type AgentSessionConfig,
diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts
index 6f8019f34..2abd6e993 100644
--- a/packages/coding-agent/src/main.ts
+++ b/packages/coding-agent/src/main.ts
@@ -5,7 +5,7 @@
* createAgentSession() options. The SDK does the heavy lifting.
*/
-import { type ImageContent, supportsXhigh } from "@mariozechner/pi-ai";
+import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai";
import chalk from "chalk";
import { existsSync } from "fs";
import { join } from "path";
@@ -35,7 +35,7 @@ import { ensureTool } from "./utils/tools-manager.js";
async function checkForNewVersion(currentVersion: string): Promise {
try {
- const response = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest");
+ const response = await fetch("https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest");
if (!response.ok) return undefined;
const data = (await response.json()) as { version?: string };
diff --git a/packages/coding-agent/src/modes/index.ts b/packages/coding-agent/src/modes/index.ts
index a29467675..3a1076019 100644
--- a/packages/coding-agent/src/modes/index.ts
+++ b/packages/coding-agent/src/modes/index.ts
@@ -2,7 +2,7 @@
* Run modes for the coding agent.
*/
-import { emergencyTerminalRestore } from "@mariozechner/pi-tui";
+import { emergencyTerminalRestore } from "@oh-my-pi/pi-tui";
/**
* Install handlers that restore terminal state on crash/signal.
diff --git a/packages/coding-agent/src/modes/interactive/components/armin.ts b/packages/coding-agent/src/modes/interactive/components/armin.ts
index aedfe61c5..b50deb374 100644
--- a/packages/coding-agent/src/modes/interactive/components/armin.ts
+++ b/packages/coding-agent/src/modes/interactive/components/armin.ts
@@ -2,7 +2,7 @@
* Armin says hi! A fun easter egg with animated XBM art.
*/
-import type { Component, TUI } from "@mariozechner/pi-tui";
+import type { Component, TUI } from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme.js";
// XBM image: 31x36 pixels, LSB first, 1=background, 0=foreground
diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts
index 01c919f3a..5eac23d1f 100644
--- a/packages/coding-agent/src/modes/interactive/components/assistant-message.ts
+++ b/packages/coding-agent/src/modes/interactive/components/assistant-message.ts
@@ -1,5 +1,5 @@
-import type { AssistantMessage } from "@mariozechner/pi-ai";
-import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+import type { AssistantMessage } from "@oh-my-pi/pi-ai";
+import { Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
diff --git a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
index 2e914fa9d..1d364c25e 100644
--- a/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
+++ b/packages/coding-agent/src/modes/interactive/components/bash-execution.ts
@@ -2,7 +2,7 @@
* Component for displaying bash command execution with streaming output.
*/
-import { Container, Loader, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
+import { Container, Loader, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import stripAnsi from "strip-ansi";
import {
DEFAULT_MAX_BYTES,
diff --git a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts
index e83f32a4a..9647cda3b 100644
--- a/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts
+++ b/packages/coding-agent/src/modes/interactive/components/bordered-loader.ts
@@ -1,4 +1,4 @@
-import { CancellableLoader, Container, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
+import { CancellableLoader, Container, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import type { Theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts
index d46b2bc52..e713b0f03 100644
--- a/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts
+++ b/packages/coding-agent/src/modes/interactive/components/branch-summary-message.ts
@@ -1,4 +1,4 @@
-import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import type { BranchSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts
index dc07d3b57..62281ee83 100644
--- a/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts
+++ b/packages/coding-agent/src/modes/interactive/components/compaction-summary-message.ts
@@ -1,4 +1,4 @@
-import { Box, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+import { Box, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import type { CompactionSummaryMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts
index 2989a604a..c1e6c3caf 100644
--- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts
+++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts
@@ -12,7 +12,7 @@ import {
isEscape,
isShiftCtrlP,
isShiftTab,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
/**
* Custom editor that handles Escape and Ctrl+C keys for coding-agent
diff --git a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts
index 2ee05ccbe..dea6e0480 100644
--- a/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts
+++ b/packages/coding-agent/src/modes/interactive/components/dynamic-border.ts
@@ -1,4 +1,4 @@
-import type { Component } from "@mariozechner/pi-tui";
+import type { Component } from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme.js";
/**
diff --git a/packages/coding-agent/src/modes/interactive/components/footer.ts b/packages/coding-agent/src/modes/interactive/components/footer.ts
index 4126c80ba..5ada17a16 100644
--- a/packages/coding-agent/src/modes/interactive/components/footer.ts
+++ b/packages/coding-agent/src/modes/interactive/components/footer.ts
@@ -1,5 +1,5 @@
-import type { AssistantMessage } from "@mariozechner/pi-ai";
-import { type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
+import type { AssistantMessage } from "@oh-my-pi/pi-ai";
+import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { execSync } from "child_process";
import { existsSync, type FSWatcher, readFileSync, watch } from "fs";
import { dirname, join } from "path";
diff --git a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts
index e54bb3ad5..cd161e13f 100644
--- a/packages/coding-agent/src/modes/interactive/components/hook-editor.ts
+++ b/packages/coding-agent/src/modes/interactive/components/hook-editor.ts
@@ -6,7 +6,7 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
-import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
+import { Container, Editor, isCtrlG, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import { getEditorTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/hook-input.ts b/packages/coding-agent/src/modes/interactive/components/hook-input.ts
index a7b30ba0d..bcd75a805 100644
--- a/packages/coding-agent/src/modes/interactive/components/hook-input.ts
+++ b/packages/coding-agent/src/modes/interactive/components/hook-input.ts
@@ -2,7 +2,7 @@
* Simple text input component for hooks.
*/
-import { Container, Input, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
+import { Container, Input, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/hook-message.ts b/packages/coding-agent/src/modes/interactive/components/hook-message.ts
index 186e902f4..56c9732e4 100644
--- a/packages/coding-agent/src/modes/interactive/components/hook-message.ts
+++ b/packages/coding-agent/src/modes/interactive/components/hook-message.ts
@@ -1,6 +1,6 @@
-import type { TextContent } from "@mariozechner/pi-ai";
-import type { Component } from "@mariozechner/pi-tui";
-import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
+import type { TextContent } from "@oh-my-pi/pi-ai";
+import type { Component } from "@oh-my-pi/pi-tui";
+import { Box, Container, Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
import type { HookMessageRenderer } from "../../../core/hooks/types.js";
import type { HookMessage } from "../../../core/messages.js";
import { getMarkdownTheme, theme } from "../theme/theme.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts
index e39a9119a..e1d2f2480 100644
--- a/packages/coding-agent/src/modes/interactive/components/hook-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/hook-selector.ts
@@ -3,7 +3,7 @@
* Displays a list of string options with keyboard navigation.
*/
-import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@mariozechner/pi-tui";
+import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/model-selector.ts b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
index 0d1ad239e..8b26bdfe6 100644
--- a/packages/coding-agent/src/modes/interactive/components/model-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/model-selector.ts
@@ -1,15 +1,5 @@
-import { type Model, modelsAreEqual } from "@mariozechner/pi-ai";
-import {
- Container,
- Input,
- isArrowDown,
- isArrowUp,
- isEnter,
- isEscape,
- Spacer,
- Text,
- type TUI,
-} from "@mariozechner/pi-tui";
+import { type Model, modelsAreEqual } from "@oh-my-pi/pi-ai";
+import { Container, Input, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
import type { ModelRegistry } from "../../../core/model-registry.js";
import type { SettingsManager } from "../../../core/settings-manager.js";
import { fuzzyFilter } from "../../../utils/fuzzy.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts
index 1a5a480be..aa59009af 100644
--- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts
@@ -1,5 +1,5 @@
-import { getOAuthProviders, type OAuthProviderInfo } from "@mariozechner/pi-ai";
-import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@mariozechner/pi-tui";
+import { getOAuthProviders, type OAuthProviderInfo } from "@oh-my-pi/pi-ai";
+import { Container, isArrowDown, isArrowUp, isEnter, isEscape, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
import type { AuthStorage } from "../../../core/auth-storage.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts b/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts
index a9590dbc5..449578611 100644
--- a/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts
+++ b/packages/coding-agent/src/modes/interactive/components/plugin-settings.ts
@@ -17,7 +17,7 @@ import {
SettingsList,
Spacer,
Text,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import { PluginManager } from "../../../core/plugins/manager.js";
import type { InstalledPlugin, PluginSettingSchema } from "../../../core/plugins/types.js";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
@@ -416,8 +416,6 @@ export class PluginSettingsComponent extends Container {
private manager: PluginManager;
private callbacks: PluginSettingsCallbacks;
private viewComponent: (Container & InputHandler) | null = null;
- private currentView: "list" | "detail" = "list";
- private currentPlugin: InstalledPlugin | null = null;
constructor(cwd: string, callbacks: PluginSettingsCallbacks) {
super();
diff --git a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts
index cebd1e5bc..551d1f458 100644
--- a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts
@@ -1,4 +1,4 @@
-import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
+import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/session-selector.ts b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
index 8a4939bee..66b7f5a8e 100644
--- a/packages/coding-agent/src/modes/interactive/components/session-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/session-selector.ts
@@ -10,7 +10,7 @@ import {
Spacer,
Text,
truncateToWidth,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import type { SessionInfo } from "../../../core/session-manager.js";
import { fuzzyFilter } from "../../../utils/fuzzy.js";
import { theme } from "../theme/theme.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
index a61061198..ef34cc11b 100644
--- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
@@ -1,4 +1,4 @@
-import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
+import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import {
Container,
getCapabilities,
@@ -8,7 +8,7 @@ import {
SettingsList,
Spacer,
Text,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import { getSelectListTheme, getSettingsListTheme, theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
import { PluginSettingsComponent } from "./plugin-settings.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts
index ea69ff254..533be6510 100644
--- a/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/show-images-selector.ts
@@ -1,4 +1,4 @@
-import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
+import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts
index f7ffc0175..ca838f2af 100644
--- a/packages/coding-agent/src/modes/interactive/components/theme-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/theme-selector.ts
@@ -1,4 +1,4 @@
-import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
+import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { getAvailableThemes, getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts
index 069de5fb5..99cbfc574 100644
--- a/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/thinking-selector.ts
@@ -1,5 +1,5 @@
-import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
-import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui";
+import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import { Container, type SelectItem, SelectList } from "@oh-my-pi/pi-tui";
import { getSelectListTheme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts
index 3ad435c75..304b46489 100644
--- a/packages/coding-agent/src/modes/interactive/components/tool-execution.ts
+++ b/packages/coding-agent/src/modes/interactive/components/tool-execution.ts
@@ -9,7 +9,7 @@ import {
Spacer,
Text,
type TUI,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import stripAnsi from "strip-ansi";
import type { CustomTool } from "../../../core/custom-tools/types.js";
import { computeEditDiff, type EditDiffError, type EditDiffResult } from "../../../core/tools/edit-diff.js";
@@ -366,7 +366,9 @@ export class ToolExecutionComponent extends Container {
warnings.push(`Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines`);
} else {
warnings.push(
- `Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)`,
+ `Truncated: ${truncation.outputLines} lines shown (${formatSize(
+ truncation.maxBytes ?? DEFAULT_MAX_BYTES,
+ )} limit)`,
);
}
}
@@ -452,14 +454,18 @@ export class ToolExecutionComponent extends Container {
"\n" +
theme.fg(
"warning",
- `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${truncation.maxLines ?? DEFAULT_MAX_LINES} line limit)]`,
+ `[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${
+ truncation.maxLines ?? DEFAULT_MAX_LINES
+ } line limit)]`,
);
} else {
text +=
"\n" +
theme.fg(
"warning",
- `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`,
+ `[Truncated: ${truncation.outputLines} lines shown (${formatSize(
+ truncation.maxBytes ?? DEFAULT_MAX_BYTES,
+ )} limit)]`,
);
}
}
diff --git a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
index bdb61e42b..c3973453f 100644
--- a/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/tree-selector.ts
@@ -16,7 +16,7 @@ import {
Text,
TruncatedText,
truncateToWidth,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import type { SessionTreeNode } from "../../../core/session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts
index 8a8f21520..a35b06bfc 100644
--- a/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/user-message-selector.ts
@@ -9,7 +9,7 @@ import {
Spacer,
Text,
truncateToWidth,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
diff --git a/packages/coding-agent/src/modes/interactive/components/user-message.ts b/packages/coding-agent/src/modes/interactive/components/user-message.ts
index 8b95a3b26..c5e86f17f 100644
--- a/packages/coding-agent/src/modes/interactive/components/user-message.ts
+++ b/packages/coding-agent/src/modes/interactive/components/user-message.ts
@@ -1,4 +1,4 @@
-import { Container, Markdown, Spacer } from "@mariozechner/pi-tui";
+import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
import { getMarkdownTheme, theme } from "../theme/theme.js";
/**
diff --git a/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts b/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts
index c681d2ba7..3131fae95 100644
--- a/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts
+++ b/packages/coding-agent/src/modes/interactive/components/visual-truncate.ts
@@ -3,7 +3,7 @@
* Used by both tool-execution.ts and bash-execution.ts for consistent behavior.
*/
-import { Text } from "@mariozechner/pi-tui";
+import { Text } from "@oh-my-pi/pi-tui";
export interface VisualTruncateResult {
/** The visual lines to display */
diff --git a/packages/coding-agent/src/modes/interactive/components/welcome.ts b/packages/coding-agent/src/modes/interactive/components/welcome.ts
index fd1d66958..361f9c2b8 100644
--- a/packages/coding-agent/src/modes/interactive/components/welcome.ts
+++ b/packages/coding-agent/src/modes/interactive/components/welcome.ts
@@ -1,4 +1,4 @@
-import { type Component, visibleWidth } from "@mariozechner/pi-tui";
+import { type Component, visibleWidth } from "@oh-my-pi/pi-tui";
import { APP_NAME } from "../../../config.js";
import { theme } from "../theme/theme.js";
@@ -44,13 +44,7 @@ export class WelcomeComponent implements Component {
// Block-based Pi logo (gradient: magenta → cyan)
// biome-ignore format: preserve ASCII art layout
- const piLogo = [
- "▀████████████▀",
- " ╘███ ███ ",
- " ███ ███ ",
- " ███ ███ ",
- " ▄███▄ ▄███▄ ",
- ];
+ const piLogo = ["▀████████████▀", " ╘███ ███ ", " ███ ███ ", " ███ ███ ", " ▄███▄ ▄███▄ "];
// Apply gradient to logo
const logoColored = piLogo.map((line) => this.gradientLine(line));
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index 3f2d5dff6..66d1d6ccf 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -6,9 +6,9 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@mariozechner/pi-ai";
-import type { SlashCommand } from "@mariozechner/pi-tui";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { AssistantMessage, ImageContent, Message, OAuthProvider } from "@oh-my-pi/pi-ai";
+import type { SlashCommand } from "@oh-my-pi/pi-tui";
import {
CombinedAutocompleteProvider,
type Component,
@@ -22,7 +22,7 @@ import {
TruncatedText,
TUI,
visibleWidth,
-} from "@mariozechner/pi-tui";
+} from "@oh-my-pi/pi-tui";
import { getAuthPath, getDebugLogPath } from "../../config.js";
import type { AgentSession, AgentSessionEvent } from "../../core/agent-session.js";
import type { CustomToolSessionEvent, LoadedCustomTool } from "../../core/custom-tools/index.js";
@@ -1543,7 +1543,7 @@ export class InteractiveMode {
theme.bold(theme.fg("warning", "Update Available")) +
"\n" +
theme.fg("muted", `New version ${newVersion} is available. Run: `) +
- theme.fg("accent", "npm install -g @mariozechner/pi-coding-agent"),
+ theme.fg("accent", "npm install -g @oh-my-pi/pi-coding-agent"),
1,
0,
),
@@ -2289,9 +2289,7 @@ export class InteractiveMode {
const hookPaths = hookRunner.getHookPaths();
if (hookPaths.length > 0) {
sections.push(
- theme.bold(theme.fg("accent", "Hooks")) +
- "\n" +
- hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n"),
+ `${theme.bold(theme.fg("accent", "Hooks"))}\n${hookPaths.map((p) => theme.fg("dim", ` ${p}`)).join("\n")}`,
);
}
}
diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts
index 6c2124336..dbf863e8d 100644
--- a/packages/coding-agent/src/modes/interactive/theme/theme.ts
+++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path";
-import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@mariozechner/pi-tui";
+import type { EditorTheme, MarkdownTheme, SelectListTheme } from "@oh-my-pi/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { TypeCompiler } from "@sinclair/typebox/compiler";
import chalk from "chalk";
@@ -987,7 +987,7 @@ export function getEditorTheme(): EditorTheme {
};
}
-export function getSettingsListTheme(): import("@mariozechner/pi-tui").SettingsListTheme {
+export function getSettingsListTheme(): import("@oh-my-pi/pi-tui").SettingsListTheme {
return {
label: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : text),
value: (text: string, selected: boolean) => (selected ? theme.fg("accent", text) : theme.fg("muted", text)),
diff --git a/packages/coding-agent/src/modes/print-mode.ts b/packages/coding-agent/src/modes/print-mode.ts
index 04ff4f363..1c60e54d1 100644
--- a/packages/coding-agent/src/modes/print-mode.ts
+++ b/packages/coding-agent/src/modes/print-mode.ts
@@ -6,7 +6,7 @@
* - `pi --mode json "prompt"` - JSON event stream
*/
-import type { AssistantMessage, ImageContent } from "@mariozechner/pi-ai";
+import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
import type { AgentSession } from "../core/agent-session.js";
/**
diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts
index e07449d36..b7bd3e1f9 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-client.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts
@@ -4,8 +4,8 @@
* Spawns the agent in RPC mode and provides a typed API for all operations.
*/
-import type { AgentEvent, AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
-import type { ImageContent } from "@mariozechner/pi-ai";
+import type { AgentEvent, AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Subprocess } from "bun";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts
index a58b86c96..fb638932f 100644
--- a/packages/coding-agent/src/modes/rpc/rpc-types.ts
+++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts
@@ -5,8 +5,8 @@
* Responses and events are emitted as JSON lines on stdout.
*/
-import type { AgentMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
-import type { ImageContent, Model } from "@mariozechner/pi-ai";
+import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
+import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
import type { SessionStats } from "../../core/agent-session.js";
import type { BashResult } from "../../core/bash-executor.js";
import type { CompactionResult } from "../../core/compaction/index.js";
diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts
index 33f70853a..9aa69fc12 100644
--- a/packages/coding-agent/test/agent-session-branching.test.ts
+++ b/packages/coding-agent/test/agent-session-branching.test.ts
@@ -10,8 +10,8 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { Agent } from "@mariozechner/pi-agent-core";
-import { getModel } from "@mariozechner/pi-ai";
+import { Agent } from "@oh-my-pi/pi-agent-core";
+import { getModel } from "@oh-my-pi/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts
index 14a664bb3..efe4061f9 100644
--- a/packages/coding-agent/test/agent-session-compaction.test.ts
+++ b/packages/coding-agent/test/agent-session-compaction.test.ts
@@ -10,8 +10,8 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { Agent } from "@mariozechner/pi-agent-core";
-import { getModel } from "@mariozechner/pi-ai";
+import { Agent } from "@oh-my-pi/pi-agent-core";
+import { getModel } from "@oh-my-pi/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession, type AgentSessionEvent } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
diff --git a/packages/coding-agent/test/compaction-hooks.test.ts b/packages/coding-agent/test/compaction-hooks.test.ts
index 5d5a51309..e21297eb0 100644
--- a/packages/coding-agent/test/compaction-hooks.test.ts
+++ b/packages/coding-agent/test/compaction-hooks.test.ts
@@ -5,8 +5,8 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
-import { Agent } from "@mariozechner/pi-agent-core";
-import { getModel } from "@mariozechner/pi-ai";
+import { Agent } from "@oh-my-pi/pi-agent-core";
+import { getModel } from "@oh-my-pi/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";
import { AuthStorage } from "../src/core/auth-storage.js";
diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts
index 4eac8b1d2..7f60fa5d9 100644
--- a/packages/coding-agent/test/compaction.test.ts
+++ b/packages/coding-agent/test/compaction.test.ts
@@ -1,6 +1,6 @@
-import type { AgentMessage } from "@mariozechner/pi-agent-core";
-import type { AssistantMessage, Usage } from "@mariozechner/pi-ai";
-import { getModel } from "@mariozechner/pi-ai";
+import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
+import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
+import { getModel } from "@oh-my-pi/pi-ai";
import { readFileSync } from "fs";
import { join } from "path";
import { beforeEach, describe, expect, it } from "vitest";
diff --git a/packages/coding-agent/test/fixtures/before-compaction.jsonl b/packages/coding-agent/test/fixtures/before-compaction.jsonl
index 5e744e3d4..769e16186 100644
--- a/packages/coding-agent/test/fixtures/before-compaction.jsonl
+++ b/packages/coding-agent/test/fixtures/before-compaction.jsonl
@@ -1,8 +1,8 @@
{"type":"session","id":"ffae836b-9420-4060-ac13-7745215f90ff","timestamp":"2025-12-09T00:53:29.825Z","cwd":"/Users/badlogic/workspaces/pi-mono","provider":"anthropic","modelId":"claude-opus-4-5","thinkingLevel":"off","branchedFrom":"/Users/badlogic/.pi/agent/sessions/--Users-badlogic-workspaces-pi-mono--/2025-12-09T00-52-54-397Z_d97339c6-6c10-4827-846b-9ff1d9c3dc37.jsonl"}
{"type":"message","timestamp":"2025-12-08T22:41:05.306Z","message":{"role":"user","content":[{"type":"text","text":"alright, read @packages/coding-agent/src/main.ts @packages/coding-agent/src/tui/tui-renderer.ts in full. i feel like this is one big mess and could be refactored to be nicer. I want you to do a deep analysis, then provide me with a plan on how to untangle this. i'm especially interested in code sharing between the different run modes (print/json, rpc, interactive). it feels like we have a lot of code duplication. for tui-renderer (which is a misnomer imo, should be interactive-mode or something, and should have rpc-mode.ts and print-mode.ts) i'm especially intersted in untangling TUI shit from agent shit if possible. but i'm not sure if that's possible nicely."}],"timestamp":1765233665292}}
{"type":"message","timestamp":"2025-12-08T22:41:09.389Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_012yuiPP1VAfh196GXaAmT8D","name":"read","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/main.ts"}},{"type":"toolCall","id":"toolu_018AGG1WjGWVfUR2Sibzkh2Q","name":"read","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/tui/tui-renderer.ts"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":2775,"output":141,"cacheRead":0,"cacheWrite":0,"totalTokens":2916,"cost":{"input":0.013875000000000002,"output":0.0035250000000000004,"cacheRead":0,"cacheWrite":0,"total":0.017400000000000002}},"stopReason":"toolUse","timestamp":1765233665294}}
-{"type":"message","timestamp":"2025-12-08T22:41:09.394Z","message":{"role":"toolResult","toolCallId":"toolu_012yuiPP1VAfh196GXaAmT8D","toolName":"read","content":[{"type":"text","text":"import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport type { Api, AssistantMessage, KnownProvider, Model } from \"@mariozechner/pi-ai\";\nimport { ProcessTerminal, TUI } from \"@mariozechner/pi-tui\";\nimport chalk from \"chalk\";\nimport { spawn } from \"child_process\";\nimport { randomBytes } from \"crypto\";\nimport { createWriteStream, existsSync, readFileSync, statSync } from \"fs\";\nimport { homedir, tmpdir } from \"os\";\nimport { extname, join, resolve } from \"path\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, getNewEntries, parseChangelog } from \"./changelog.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"./compaction.js\";\nimport {\n\tAPP_NAME,\n\tCONFIG_DIR_NAME,\n\tENV_AGENT_DIR,\n\tgetAgentDir,\n\tgetModelsPath,\n\tgetReadmePath,\n\tVERSION,\n} from \"./config.js\";\nimport { exportFromFile } from \"./export-html.js\";\nimport { type BashExecutionMessage, messageTransformer } from \"./messages.js\";\nimport { findModel, getApiKeyForModel, getAvailableModels } from \"./model-config.js\";\nimport { loadSessionFromEntries, SessionManager } from \"./session-manager.js\";\nimport { SettingsManager } from \"./settings-manager.js\";\nimport { getShellConfig } from \"./shell.js\";\nimport { expandSlashCommand, loadSlashCommands } from \"./slash-commands.js\";\nimport { initTheme } from \"./theme/theme.js\";\nimport { allTools, codingTools, type ToolName } from \"./tools/index.js\";\nimport { DEFAULT_MAX_BYTES, truncateTail } from \"./tools/truncate.js\";\nimport { ensureTool } from \"./tools-manager.js\";\nimport { SessionSelectorComponent } from \"./tui/session-selector.js\";\nimport { TuiRenderer } from \"./tui/tui-renderer.js\";\n\nconst defaultModelPerProvider: Record = {\n\tanthropic: \"claude-sonnet-4-5\",\n\topenai: \"gpt-5.1-codex\",\n\tgoogle: \"gemini-2.5-pro\",\n\topenrouter: \"openai/gpt-5.1-codex\",\n\txai: \"grok-4-fast-non-reasoning\",\n\tgroq: \"openai/gpt-oss-120b\",\n\tcerebras: \"zai-glm-4.6\",\n\tzai: \"glm-4.6\",\n};\n\ntype Mode = \"text\" | \"json\" | \"rpc\";\n\ninterface Args {\n\tprovider?: string;\n\tmodel?: string;\n\tapiKey?: string;\n\tsystemPrompt?: string;\n\tappendSystemPrompt?: string;\n\tthinking?: ThinkingLevel;\n\tcontinue?: boolean;\n\tresume?: boolean;\n\thelp?: boolean;\n\tmode?: Mode;\n\tnoSession?: boolean;\n\tsession?: string;\n\tmodels?: string[];\n\ttools?: ToolName[];\n\tprint?: boolean;\n\texport?: string;\n\tmessages: string[];\n\tfileArgs: string[];\n}\n\nfunction parseArgs(args: string[]): Args {\n\tconst result: Args = {\n\t\tmessages: [],\n\t\tfileArgs: [],\n\t};\n\n\tfor (let i = 0; i < args.length; i++) {\n\t\tconst arg = args[i];\n\n\t\tif (arg === \"--help\" || arg === \"-h\") {\n\t\t\tresult.help = true;\n\t\t} else if (arg === \"--mode\" && i + 1 < args.length) {\n\t\t\tconst mode = args[++i];\n\t\t\tif (mode === \"text\" || mode === \"json\" || mode === \"rpc\") {\n\t\t\t\tresult.mode = mode;\n\t\t\t}\n\t\t} else if (arg === \"--continue\" || arg === \"-c\") {\n\t\t\tresult.continue = true;\n\t\t} else if (arg === \"--resume\" || arg === \"-r\") {\n\t\t\tresult.resume = true;\n\t\t} else if (arg === \"--provider\" && i + 1 < args.length) {\n\t\t\tresult.provider = args[++i];\n\t\t} else if (arg === \"--model\" && i + 1 < args.length) {\n\t\t\tresult.model = args[++i];\n\t\t} else if (arg === \"--api-key\" && i + 1 < args.length) {\n\t\t\tresult.apiKey = args[++i];\n\t\t} else if (arg === \"--system-prompt\" && i + 1 < args.length) {\n\t\t\tresult.systemPrompt = args[++i];\n\t\t} else if (arg === \"--append-system-prompt\" && i + 1 < args.length) {\n\t\t\tresult.appendSystemPrompt = args[++i];\n\t\t} else if (arg === \"--no-session\") {\n\t\t\tresult.noSession = true;\n\t\t} else if (arg === \"--session\" && i + 1 < args.length) {\n\t\t\tresult.session = args[++i];\n\t\t} else if (arg === \"--models\" && i + 1 < args.length) {\n\t\t\tresult.models = args[++i].split(\",\").map((s) => s.trim());\n\t\t} else if (arg === \"--tools\" && i + 1 < args.length) {\n\t\t\tconst toolNames = args[++i].split(\",\").map((s) => s.trim());\n\t\t\tconst validTools: ToolName[] = [];\n\t\t\tfor (const name of toolNames) {\n\t\t\t\tif (name in allTools) {\n\t\t\t\t\tvalidTools.push(name as ToolName);\n\t\t\t\t} else {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\tchalk.yellow(`Warning: Unknown tool \"${name}\". Valid tools: ${Object.keys(allTools).join(\", \")}`),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult.tools = validTools;\n\t\t} else if (arg === \"--thinking\" && i + 1 < args.length) {\n\t\t\tconst level = args[++i];\n\t\t\tif (\n\t\t\t\tlevel === \"off\" ||\n\t\t\t\tlevel === \"minimal\" ||\n\t\t\t\tlevel === \"low\" ||\n\t\t\t\tlevel === \"medium\" ||\n\t\t\t\tlevel === \"high\" ||\n\t\t\t\tlevel === \"xhigh\"\n\t\t\t) {\n\t\t\t\tresult.thinking = level;\n\t\t\t} else {\n\t\t\t\tconsole.error(\n\t\t\t\t\tchalk.yellow(\n\t\t\t\t\t\t`Warning: Invalid thinking level \"${level}\". Valid values: off, minimal, low, medium, high, xhigh`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (arg === \"--print\" || arg === \"-p\") {\n\t\t\tresult.print = true;\n\t\t} else if (arg === \"--export\" && i + 1 < args.length) {\n\t\t\tresult.export = args[++i];\n\t\t} else if (arg.startsWith(\"@\")) {\n\t\t\tresult.fileArgs.push(arg.slice(1)); // Remove @ prefix\n\t\t} else if (!arg.startsWith(\"-\")) {\n\t\t\tresult.messages.push(arg);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Map of file extensions to MIME types for common image formats\n */\nconst IMAGE_MIME_TYPES: Record = {\n\t\".jpg\": \"image/jpeg\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".png\": \"image/png\",\n\t\".gif\": \"image/gif\",\n\t\".webp\": \"image/webp\",\n};\n\n/**\n * Check if a file is an image based on its extension\n */\nfunction isImageFile(filePath: string): string | null {\n\tconst ext = extname(filePath).toLowerCase();\n\treturn IMAGE_MIME_TYPES[ext] || null;\n}\n\n/**\n * Expand ~ to home directory\n */\nfunction expandPath(filePath: string): string {\n\tif (filePath === \"~\") {\n\t\treturn homedir();\n\t}\n\tif (filePath.startsWith(\"~/\")) {\n\t\treturn homedir() + filePath.slice(1);\n\t}\n\treturn filePath;\n}\n\n/**\n * Process @file arguments into text content and image attachments\n */\nfunction processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } {\n\tlet textContent = \"\";\n\tconst imageAttachments: Attachment[] = [];\n\n\tfor (const fileArg of fileArgs) {\n\t\t// Expand and resolve path\n\t\tconst expandedPath = expandPath(fileArg);\n\t\tconst absolutePath = resolve(expandedPath);\n\n\t\t// Check if file exists\n\t\tif (!existsSync(absolutePath)) {\n\t\t\tconsole.error(chalk.red(`Error: File not found: ${absolutePath}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\t// Check if file is empty\n\t\tconst stats = statSync(absolutePath);\n\t\tif (stats.size === 0) {\n\t\t\t// Skip empty files\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst mimeType = isImageFile(absolutePath);\n\n\t\tif (mimeType) {\n\t\t\t// Handle image file\n\t\t\tconst content = readFileSync(absolutePath);\n\t\t\tconst base64Content = content.toString(\"base64\");\n\n\t\t\tconst attachment: Attachment = {\n\t\t\t\tid: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n\t\t\t\ttype: \"image\",\n\t\t\t\tfileName: absolutePath.split(\"/\").pop() || absolutePath,\n\t\t\t\tmimeType,\n\t\t\t\tsize: stats.size,\n\t\t\t\tcontent: base64Content,\n\t\t\t};\n\n\t\t\timageAttachments.push(attachment);\n\n\t\t\t// Add text reference to image\n\t\t\ttextContent += ` \\n`;\n\t\t} else {\n\t\t\t// Handle text file\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(absolutePath, \"utf-8\");\n\t\t\t\ttextContent += `\\n${content}\\n \\n`;\n\t\t\t} catch (error: any) {\n\t\t\t\tconsole.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { textContent, imageAttachments };\n}\n\nfunction printHelp() {\n\tconsole.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools\n\n${chalk.bold(\"Usage:\")}\n ${APP_NAME} [options] [@files...] [messages...]\n\n${chalk.bold(\"Options:\")}\n --provider Provider name (default: google)\n --model Model ID (default: gemini-2.5-flash)\n --api-key API key (defaults to env vars)\n --system-prompt System prompt (default: coding assistant prompt)\n --append-system-prompt Append text or file contents to the system prompt\n --mode Output mode: text (default), json, or rpc\n --print, -p Non-interactive mode: process prompt and exit\n --continue, -c Continue previous session\n --resume, -r Select a session to resume\n --session Use specific session file\n --no-session Don't save session (ephemeral)\n --models Comma-separated model patterns for quick cycling with Ctrl+P\n --tools Comma-separated list of tools to enable (default: read,bash,edit,write)\n Available: read, bash, edit, write, grep, find, ls\n --thinking Set thinking level: off, minimal, low, medium, high, xhigh\n --export Export session file to HTML and exit\n --help, -h Show this help\n\n${chalk.bold(\"Examples:\")}\n # Interactive mode\n ${APP_NAME}\n\n # Interactive mode with initial prompt\n ${APP_NAME} \"List all .ts files in src/\"\n\n # Include files in initial message\n ${APP_NAME} @prompt.md @image.png \"What color is the sky?\"\n\n # Non-interactive mode (process and exit)\n ${APP_NAME} -p \"List all .ts files in src/\"\n\n # Multiple messages (interactive)\n ${APP_NAME} \"Read package.json\" \"What dependencies do we have?\"\n\n # Continue previous session\n ${APP_NAME} --continue \"What did we discuss?\"\n\n # Use different model\n ${APP_NAME} --provider openai --model gpt-4o-mini \"Help me refactor this code\"\n\n # Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o\n\n # Cycle models with fixed thinking levels\n ${APP_NAME} --models sonnet:high,haiku:low\n\n # Start with a specific thinking level\n ${APP_NAME} --thinking high \"Solve this complex problem\"\n\n # Read-only mode (no file modifications possible)\n ${APP_NAME} --tools read,grep,find,ls -p \"Review the code in src/\"\n\n # Export a session file to HTML\n ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl\n ${APP_NAME} --export session.jsonl output.html\n\n${chalk.bold(\"Environment Variables:\")}\n ANTHROPIC_API_KEY - Anthropic Claude API key\n ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)\n OPENAI_API_KEY - OpenAI GPT API key\n GEMINI_API_KEY - Google Gemini API key\n GROQ_API_KEY - Groq API key\n CEREBRAS_API_KEY - Cerebras API key\n XAI_API_KEY - xAI Grok API key\n OPENROUTER_API_KEY - OpenRouter API key\n ZAI_API_KEY - ZAI API key\n ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)\n\n${chalk.bold(\"Available Tools (default: read, bash, edit, write):\")}\n read - Read file contents\n bash - Execute bash commands\n edit - Edit files with find/replace\n write - Write files (creates/overwrites)\n grep - Search file contents (read-only, off by default)\n find - Find files by glob pattern (read-only, off by default)\n ls - List directory contents (read-only, off by default)\n`);\n}\n\n// Tool descriptions for system prompt\nconst toolDescriptions: Record = {\n\tread: \"Read file contents\",\n\tbash: \"Execute bash commands (ls, grep, find, etc.)\",\n\tedit: \"Make surgical edits to files (find exact text and replace)\",\n\twrite: \"Create or overwrite files\",\n\tgrep: \"Search file contents for patterns (respects .gitignore)\",\n\tfind: \"Find files by glob pattern (respects .gitignore)\",\n\tls: \"List directory contents\",\n};\n\nfunction resolvePromptInput(input: string | undefined, description: string): string | undefined {\n\tif (!input) {\n\t\treturn undefined;\n\t}\n\n\tif (existsSync(input)) {\n\t\ttry {\n\t\t\treturn readFileSync(input, \"utf-8\");\n\t\t} catch (error) {\n\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));\n\t\t\treturn input;\n\t\t}\n\t}\n\n\treturn input;\n}\n\nfunction buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string {\n\tconst resolvedCustomPrompt = resolvePromptInput(customPrompt, \"system prompt\");\n\tconst resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, \"append system prompt\");\n\n\tconst now = new Date();\n\tconst dateTime = now.toLocaleString(\"en-US\", {\n\t\tweekday: \"long\",\n\t\tyear: \"numeric\",\n\t\tmonth: \"long\",\n\t\tday: \"numeric\",\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t\tsecond: \"2-digit\",\n\t\ttimeZoneName: \"short\",\n\t});\n\n\tconst appendSection = resolvedAppendPrompt ? `\\n\\n${resolvedAppendPrompt}` : \"\";\n\n\tif (resolvedCustomPrompt) {\n\t\tlet prompt = resolvedCustomPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tconst contextFiles = loadProjectContextFiles();\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"The following project context files have been loaded:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Add date/time and working directory last\n\t\tprompt += `\\nCurrent date and time: ${dateTime}`;\n\t\tprompt += `\\nCurrent working directory: ${process.cwd()}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Get absolute path to README.md\n\tconst readmePath = getReadmePath();\n\n\t// Build tools list based on selected tools\n\tconst tools = selectedTools || ([\"read\", \"bash\", \"edit\", \"write\"] as ToolName[]);\n\tconst toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join(\"\\n\");\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasEdit = tools.includes(\"edit\");\n\tconst hasWrite = tools.includes(\"write\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// Read-only mode notice (no bash, edit, or write)\n\tif (!hasBash && !hasEdit && !hasWrite) {\n\t\tguidelinesList.push(\"You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands\");\n\t}\n\n\t// Bash without edit/write = read-only bash mode\n\tif (hasBash && !hasEdit && !hasWrite) {\n\t\tguidelinesList.push(\n\t\t\t\"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files\",\n\t\t);\n\t}\n\n\t// File exploration guidelines\n\tif (hasBash && !hasGrep && !hasFind && !hasLs) {\n\t\tguidelinesList.push(\"Use bash for file operations like ls, grep, find\");\n\t} else if (hasBash && (hasGrep || hasFind || hasLs)) {\n\t\tguidelinesList.push(\"Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)\");\n\t}\n\n\t// Read before edit guideline\n\tif (hasRead && hasEdit) {\n\t\tguidelinesList.push(\"Use read to examine files before editing\");\n\t}\n\n\t// Edit guideline\n\tif (hasEdit) {\n\t\tguidelinesList.push(\"Use edit for precise changes (old text must match exactly)\");\n\t}\n\n\t// Write guideline\n\tif (hasWrite) {\n\t\tguidelinesList.push(\"Use write only for new files or complete rewrites\");\n\t}\n\n\t// Output guideline (only when actually writing/executing)\n\tif (hasEdit || hasWrite) {\n\t\tguidelinesList.push(\n\t\t\t\"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did\",\n\t\t);\n\t}\n\n\t// Always include these\n\tguidelinesList.push(\"Be concise in your responses\");\n\tguidelinesList.push(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nGuidelines:\n${guidelines}\n\nDocumentation:\n- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}\n- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tconst contextFiles = loadProjectContextFiles();\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"The following project context files have been loaded:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Add date/time and working directory last\n\tprompt += `\\nCurrent date and time: ${dateTime}`;\n\tprompt += `\\nCurrent working directory: ${process.cwd()}`;\n\n\treturn prompt;\n}\n\n/**\n * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md)\n */\nfunction loadContextFileFromDir(dir: string): { path: string; content: string } | null {\n\tconst candidates = [\"AGENTS.md\", \"CLAUDE.md\"];\n\tfor (const filename of candidates) {\n\t\tconst filePath = join(dir, filename);\n\t\tif (existsSync(filePath)) {\n\t\t\ttry {\n\t\t\t\treturn {\n\t\t\t\t\tpath: filePath,\n\t\t\t\t\tcontent: readFileSync(filePath, \"utf-8\"),\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));\n\t\t\t}\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Load all project context files in order:\n * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md\n * 2. Parent directories (top-most first) down to cwd\n * Each returns {path, content} for separate messages\n */\nfunction loadProjectContextFiles(): Array<{ path: string; content: string }> {\n\tconst contextFiles: Array<{ path: string; content: string }> = [];\n\n\t// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/\n\tconst globalContextDir = getAgentDir();\n\tconst globalContext = loadContextFileFromDir(globalContextDir);\n\tif (globalContext) {\n\t\tcontextFiles.push(globalContext);\n\t}\n\n\t// 2. Walk up from cwd to root, collecting all context files\n\tconst cwd = process.cwd();\n\tconst ancestorContextFiles: Array<{ path: string; content: string }> = [];\n\n\tlet currentDir = cwd;\n\tconst root = resolve(\"/\");\n\n\twhile (true) {\n\t\tconst contextFile = loadContextFileFromDir(currentDir);\n\t\tif (contextFile) {\n\t\t\t// Add to beginning so we get top-most parent first\n\t\t\tancestorContextFiles.unshift(contextFile);\n\t\t}\n\n\t\t// Stop if we've reached root\n\t\tif (currentDir === root) break;\n\n\t\t// Move up one directory\n\t\tconst parentDir = resolve(currentDir, \"..\");\n\t\tif (parentDir === currentDir) break; // Safety check\n\t\tcurrentDir = parentDir;\n\t}\n\n\t// Add ancestor files in order (top-most → cwd)\n\tcontextFiles.push(...ancestorContextFiles);\n\n\treturn contextFiles;\n}\n\nasync function checkForNewVersion(currentVersion: string): Promise {\n\ttry {\n\t\tconst response = await fetch(\"https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest\");\n\t\tif (!response.ok) return null;\n\n\t\tconst data = (await response.json()) as { version?: string };\n\t\tconst latestVersion = data.version;\n\n\t\tif (latestVersion && latestVersion !== currentVersion) {\n\t\t\treturn latestVersion;\n\t\t}\n\n\t\treturn null;\n\t} catch (error) {\n\t\t// Silently fail - don't disrupt the user experience\n\t\treturn null;\n\t}\n}\n\n/**\n * Resolve model patterns to actual Model objects with optional thinking levels\n * Format: \"pattern:level\" where :level is optional\n * For each pattern, finds all matching models and picks the best version:\n * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)\n * 2. If no alias, pick the latest dated version\n */\nasync function resolveModelScope(\n\tpatterns: string[],\n): Promise; thinkingLevel: ThinkingLevel }>> {\n\tconst { models: availableModels, error } = await getAvailableModels();\n\n\tif (error) {\n\t\tconsole.warn(chalk.yellow(`Warning: Error loading models: ${error}`));\n\t\treturn [];\n\t}\n\n\tconst scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\tfor (const pattern of patterns) {\n\t\t// Parse pattern:level format\n\t\tconst parts = pattern.split(\":\");\n\t\tconst modelPattern = parts[0];\n\t\tlet thinkingLevel: ThinkingLevel = \"off\";\n\n\t\tif (parts.length > 1) {\n\t\t\tconst level = parts[1];\n\t\t\tif (\n\t\t\t\tlevel === \"off\" ||\n\t\t\t\tlevel === \"minimal\" ||\n\t\t\t\tlevel === \"low\" ||\n\t\t\t\tlevel === \"medium\" ||\n\t\t\t\tlevel === \"high\" ||\n\t\t\t\tlevel === \"xhigh\"\n\t\t\t) {\n\t\t\t\tthinkingLevel = level;\n\t\t\t} else {\n\t\t\t\tconsole.warn(\n\t\t\t\t\tchalk.yellow(`Warning: Invalid thinking level \"${level}\" in pattern \"${pattern}\". Using \"off\" instead.`),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Check for provider/modelId format (provider is everything before the first /)\n\t\tconst slashIndex = modelPattern.indexOf(\"/\");\n\t\tif (slashIndex !== -1) {\n\t\t\tconst provider = modelPattern.substring(0, slashIndex);\n\t\t\tconst modelId = modelPattern.substring(slashIndex + 1);\n\t\t\tconst providerMatch = availableModels.find(\n\t\t\t\t(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatch) {\n\t\t\t\tif (\n\t\t\t\t\t!scopedModels.find(\n\t\t\t\t\t\t(sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tscopedModels.push({ model: providerMatch, thinkingLevel });\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No exact provider/model match - fall through to other matching\n\t\t}\n\n\t\t// Check for exact ID match (case-insensitive)\n\t\tconst exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase());\n\t\tif (exactMatch) {\n\t\t\t// Exact match found - use it directly\n\t\t\tif (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) {\n\t\t\t\tscopedModels.push({ model: exactMatch, thinkingLevel });\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No exact match - fall back to partial matching\n\t\tconst matches = availableModels.filter(\n\t\t\t(m) =>\n\t\t\t\tm.id.toLowerCase().includes(modelPattern.toLowerCase()) ||\n\t\t\t\tm.name?.toLowerCase().includes(modelPattern.toLowerCase()),\n\t\t);\n\n\t\tif (matches.length === 0) {\n\t\t\tconsole.warn(chalk.yellow(`Warning: No models match pattern \"${modelPattern}\"`));\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Helper to check if a model ID looks like an alias (no date suffix)\n\t\t// Dates are typically in format: -20241022 or -20250929\n\t\tconst isAlias = (id: string): boolean => {\n\t\t\t// Check if ID ends with -latest\n\t\t\tif (id.endsWith(\"-latest\")) return true;\n\n\t\t\t// Check if ID ends with a date pattern (-YYYYMMDD)\n\t\t\tconst datePattern = /-\\d{8}$/;\n\t\t\treturn !datePattern.test(id);\n\t\t};\n\n\t\t// Separate into aliases and dated versions\n\t\tconst aliases = matches.filter((m) => isAlias(m.id));\n\t\tconst datedVersions = matches.filter((m) => !isAlias(m.id));\n\n\t\tlet bestMatch: Model;\n\n\t\tif (aliases.length > 0) {\n\t\t\t// Prefer alias - if multiple aliases, pick the one that sorts highest\n\t\t\taliases.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = aliases[0];\n\t\t} else {\n\t\t\t// No alias found, pick latest dated version\n\t\t\tdatedVersions.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = datedVersions[0];\n\t\t}\n\n\t\t// Avoid duplicates\n\t\tif (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) {\n\t\t\tscopedModels.push({ model: bestMatch, thinkingLevel });\n\t\t}\n\t}\n\n\treturn scopedModels;\n}\n\nasync function selectSession(sessionManager: SessionManager): Promise {\n\treturn new Promise((resolve) => {\n\t\tconst ui = new TUI(new ProcessTerminal());\n\t\tlet resolved = false;\n\n\t\tconst selector = new SessionSelectorComponent(\n\t\t\tsessionManager,\n\t\t\t(path: string) => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tui.stop();\n\t\t\t\t\tresolve(path);\n\t\t\t\t}\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tui.stop();\n\t\t\t\t\tresolve(null);\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\n\t\tui.addChild(selector);\n\t\tui.setFocus(selector.getSessionList());\n\t\tui.start();\n\t});\n}\n\nasync function runInteractiveMode(\n\tagent: Agent,\n\tsessionManager: SessionManager,\n\tsettingsManager: SettingsManager,\n\tversion: string,\n\tchangelogMarkdown: string | null = null,\n\tcollapseChangelog = false,\n\tmodelFallbackMessage: string | null = null,\n\tversionCheckPromise: Promise,\n\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\tinitialMessages: string[] = [],\n\tinitialMessage?: string,\n\tinitialAttachments?: Attachment[],\n\tfdPath: string | null = null,\n): Promise {\n\tconst renderer = new TuiRenderer(\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager,\n\t\tversion,\n\t\tchangelogMarkdown,\n\t\tcollapseChangelog,\n\t\tscopedModels,\n\t\tfdPath,\n\t);\n\n\t// Initialize TUI (subscribes to agent events internally)\n\tawait renderer.init();\n\n\t// Handle version check result when it completes (don't block)\n\tversionCheckPromise.then((newVersion) => {\n\t\tif (newVersion) {\n\t\t\trenderer.showNewVersionNotification(newVersion);\n\t\t}\n\t});\n\n\t// Render any existing messages (from --continue mode)\n\trenderer.renderInitialMessages(agent.state);\n\n\t// Show model fallback warning at the end of the chat if applicable\n\tif (modelFallbackMessage) {\n\t\trenderer.showWarning(modelFallbackMessage);\n\t}\n\n\t// Load file-based slash commands for expansion\n\tconst fileCommands = loadSlashCommands();\n\n\t// Process initial message with attachments if provided (from @file args)\n\tif (initialMessage) {\n\t\ttry {\n\t\t\tawait agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Process remaining initial messages if provided (from CLI args)\n\tfor (const message of initialMessages) {\n\t\ttry {\n\t\t\tawait agent.prompt(expandSlashCommand(message, fileCommands));\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Interactive loop\n\twhile (true) {\n\t\tconst userInput = await renderer.getUserInput();\n\n\t\t// Process the message - agent.prompt will add user message and trigger state updates\n\t\ttry {\n\t\t\tawait agent.prompt(userInput);\n\t\t} catch (error: unknown) {\n\t\t\t// Display error in the TUI by adding an error message to the chat\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n}\n\nasync function runSingleShotMode(\n\tagent: Agent,\n\t_sessionManager: SessionManager,\n\tmessages: string[],\n\tmode: \"text\" | \"json\",\n\tinitialMessage?: string,\n\tinitialAttachments?: Attachment[],\n): Promise {\n\t// Load file-based slash commands for expansion\n\tconst fileCommands = loadSlashCommands();\n\n\tif (mode === \"json\") {\n\t\t// Subscribe to all events and output as JSON\n\t\tagent.subscribe((event) => {\n\t\t\t// Output event as JSON (same format as session manager)\n\t\t\tconsole.log(JSON.stringify(event));\n\t\t});\n\t}\n\n\t// Send initial message with attachments if provided\n\tif (initialMessage) {\n\t\tawait agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments);\n\t}\n\n\t// Send remaining messages\n\tfor (const message of messages) {\n\t\tawait agent.prompt(expandSlashCommand(message, fileCommands));\n\t}\n\n\t// In text mode, only output the final assistant message\n\tif (mode === \"text\") {\n\t\tconst lastMessage = agent.state.messages[agent.state.messages.length - 1];\n\t\tif (lastMessage.role === \"assistant\") {\n\t\t\tconst assistantMsg = lastMessage as AssistantMessage;\n\n\t\t\t// Check for error/aborted and output error message\n\t\t\tif (assistantMsg.stopReason === \"error\" || assistantMsg.stopReason === \"aborted\") {\n\t\t\t\tconsole.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\n\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\tif (content.type === \"text\") {\n\t\t\t\t\tconsole.log(content.text);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Execute a bash command for RPC mode.\n * Similar to tui-renderer's executeBashCommand but without streaming callbacks.\n */\nasync function executeRpcBashCommand(command: string): Promise<{\n\toutput: string;\n\texitCode: number | null;\n\ttruncationResult?: ReturnType;\n\tfullOutputPath?: string;\n}> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst { shell, args } = getShellConfig();\n\t\tconst child = spawn(shell, [...args, command], {\n\t\t\tdetached: true,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\n\t\tconst chunks: Buffer[] = [];\n\t\tlet chunksBytes = 0;\n\t\tconst maxChunksBytes = DEFAULT_MAX_BYTES * 2;\n\n\t\tlet tempFilePath: string | undefined;\n\t\tlet tempFileStream: ReturnType | undefined;\n\t\tlet totalBytes = 0;\n\n\t\tconst handleData = (data: Buffer) => {\n\t\t\ttotalBytes += data.length;\n\n\t\t\t// Start writing to temp file if exceeds threshold\n\t\t\tif (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {\n\t\t\t\tconst id = randomBytes(8).toString(\"hex\");\n\t\t\t\ttempFilePath = join(tmpdir(), `pi-bash-${id}.log`);\n\t\t\t\ttempFileStream = createWriteStream(tempFilePath);\n\t\t\t\tfor (const chunk of chunks) {\n\t\t\t\t\ttempFileStream.write(chunk);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.write(data);\n\t\t\t}\n\n\t\t\t// Keep rolling buffer\n\t\t\tchunks.push(data);\n\t\t\tchunksBytes += data.length;\n\t\t\twhile (chunksBytes > maxChunksBytes && chunks.length > 1) {\n\t\t\t\tconst removed = chunks.shift()!;\n\t\t\t\tchunksBytes -= removed.length;\n\t\t\t}\n\t\t};\n\n\t\tchild.stdout?.on(\"data\", handleData);\n\t\tchild.stderr?.on(\"data\", handleData);\n\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.end();\n\t\t\t}\n\n\t\t\t// Combine buffered chunks\n\t\t\tconst fullBuffer = Buffer.concat(chunks);\n\t\t\tconst fullOutput = stripAnsi(fullBuffer.toString(\"utf-8\")).replace(/\\r/g, \"\");\n\t\t\tconst truncationResult = truncateTail(fullOutput);\n\n\t\t\tresolve({\n\t\t\t\toutput: fullOutput,\n\t\t\t\texitCode: code,\n\t\t\t\ttruncationResult: truncationResult.truncated ? truncationResult : undefined,\n\t\t\t\tfullOutputPath: tempFilePath,\n\t\t\t});\n\t\t});\n\n\t\tchild.on(\"error\", (err) => {\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.end();\n\t\t\t}\n\t\t\treject(err);\n\t\t});\n\t});\n}\n\nasync function runRpcMode(\n\tagent: Agent,\n\tsessionManager: SessionManager,\n\tsettingsManager: SettingsManager,\n): Promise {\n\t// Track if auto-compaction is in progress\n\tlet autoCompactionInProgress = false;\n\n\t// Auto-compaction helper\n\tconst checkAutoCompaction = async () => {\n\t\tif (autoCompactionInProgress) return;\n\n\t\tconst settings = settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message\n\t\tconst messages = agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tautoCompactionInProgress = true;\n\t\ttry {\n\t\t\tconst apiKey = await getApiKeyForModel(agent.state.model);\n\t\t\tif (!apiKey) {\n\t\t\t\tthrow new Error(`No API key for ${agent.state.model.provider}`);\n\t\t\t}\n\n\t\t\tconst entries = sessionManager.loadEntries();\n\t\t\tconst compactionEntry = await compact(entries, agent.state.model, settings, apiKey);\n\n\t\t\tsessionManager.saveCompaction(compactionEntry);\n\t\t\tconst loaded = loadSessionFromEntries(sessionManager.loadEntries());\n\t\t\tagent.replaceMessages(loaded.messages);\n\n\t\t\t// Emit auto-compaction event\n\t\t\tconsole.log(JSON.stringify({ ...compactionEntry, auto: true }));\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Auto-compaction failed: ${message}` }));\n\t\t} finally {\n\t\t\tautoCompactionInProgress = false;\n\t\t}\n\t};\n\n\t// Subscribe to all events and output as JSON (same pattern as tui-renderer)\n\tagent.subscribe(async (event) => {\n\t\tconsole.log(JSON.stringify(event));\n\n\t\t// Save messages to session\n\t\tif (event.type === \"message_end\") {\n\t\t\tsessionManager.saveMessage(event.message);\n\n\t\t\t// Yield to microtask queue to allow agent state to update\n\t\t\t// (tui-renderer does this implicitly via await handleEvent)\n\t\t\tawait Promise.resolve();\n\n\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\tif (sessionManager.shouldInitializeSession(agent.state.messages)) {\n\t\t\t\tsessionManager.startSession(agent.state);\n\t\t\t}\n\n\t\t\t// Check for auto-compaction after assistant messages\n\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\tawait checkAutoCompaction();\n\t\t\t}\n\t\t}\n\t});\n\n\t// Listen for JSON input on stdin\n\tconst readline = await import(\"readline\");\n\tconst rl = readline.createInterface({\n\t\tinput: process.stdin,\n\t\toutput: process.stdout,\n\t\tterminal: false,\n\t});\n\n\trl.on(\"line\", async (line: string) => {\n\t\ttry {\n\t\t\tconst input = JSON.parse(line);\n\n\t\t\t// Handle different RPC commands\n\t\t\tif (input.type === \"prompt\" && input.message) {\n\t\t\t\tawait agent.prompt(input.message, input.attachments);\n\t\t\t} else if (input.type === \"abort\") {\n\t\t\t\tagent.abort();\n\t\t\t} else if (input.type === \"compact\") {\n\t\t\t\t// Handle compaction request\n\t\t\t\ttry {\n\t\t\t\t\tconst apiKey = await getApiKeyForModel(agent.state.model);\n\t\t\t\t\tif (!apiKey) {\n\t\t\t\t\t\tthrow new Error(`No API key for ${agent.state.model.provider}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst entries = sessionManager.loadEntries();\n\t\t\t\t\tconst settings = settingsManager.getCompactionSettings();\n\t\t\t\t\tconst compactionEntry = await compact(\n\t\t\t\t\t\tentries,\n\t\t\t\t\t\tagent.state.model,\n\t\t\t\t\t\tsettings,\n\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\tinput.customInstructions,\n\t\t\t\t\t);\n\n\t\t\t\t\t// Save and reload\n\t\t\t\t\tsessionManager.saveCompaction(compactionEntry);\n\t\t\t\t\tconst loaded = loadSessionFromEntries(sessionManager.loadEntries());\n\t\t\t\t\tagent.replaceMessages(loaded.messages);\n\n\t\t\t\t\t// Emit compaction event (compactionEntry already has type: \"compaction\")\n\t\t\t\t\tconsole.log(JSON.stringify(compactionEntry));\n\t\t\t\t} catch (error: any) {\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Compaction failed: ${error.message}` }));\n\t\t\t\t}\n\t\t\t} else if (input.type === \"bash\" && input.command) {\n\t\t\t\t// Execute bash command and add to context\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await executeRpcBashCommand(input.command);\n\n\t\t\t\t\t// Create bash execution message\n\t\t\t\t\tconst bashMessage: BashExecutionMessage = {\n\t\t\t\t\t\trole: \"bashExecution\",\n\t\t\t\t\t\tcommand: input.command,\n\t\t\t\t\t\toutput: result.truncationResult?.content || result.output,\n\t\t\t\t\t\texitCode: result.exitCode,\n\t\t\t\t\t\tcancelled: false,\n\t\t\t\t\t\ttruncated: result.truncationResult?.truncated || false,\n\t\t\t\t\t\tfullOutputPath: result.fullOutputPath,\n\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t};\n\n\t\t\t\t\t// Add to agent state and save to session\n\t\t\t\t\tagent.appendMessage(bashMessage);\n\t\t\t\t\tsessionManager.saveMessage(bashMessage);\n\n\t\t\t\t\t// Initialize session if needed (same logic as message_end handler)\n\t\t\t\t\tif (sessionManager.shouldInitializeSession(agent.state.messages)) {\n\t\t\t\t\t\tsessionManager.startSession(agent.state);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Emit bash_end event with the message\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"bash_end\", message: bashMessage }));\n\t\t\t\t} catch (error: any) {\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Bash command failed: ${error.message}` }));\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error: any) {\n\t\t\t// Output error as JSON\n\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: error.message }));\n\t\t}\n\t});\n\n\t// Keep process alive\n\treturn new Promise(() => {});\n}\n\nexport async function main(args: string[]) {\n\tconst parsed = parseArgs(args);\n\n\tif (parsed.help) {\n\t\tprintHelp();\n\t\treturn;\n\t}\n\n\t// Handle --export flag: convert session file to HTML and exit\n\tif (parsed.export) {\n\t\ttry {\n\t\t\t// Use first message as output path if provided\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tconst result = exportFromFile(parsed.export, outputPath);\n\t\t\tconsole.log(`Exported to: ${result}`);\n\t\t\treturn;\n\t\t} catch (error: any) {\n\t\t\tconsole.error(chalk.red(`Error: ${error.message || \"Failed to export session\"}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\t// Validate: RPC mode doesn't support @file arguments\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\t// Process @file arguments if any\n\tlet initialMessage: string | undefined;\n\tlet initialAttachments: Attachment[] | undefined;\n\n\tif (parsed.fileArgs.length > 0) {\n\t\tconst { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);\n\n\t\t// Combine file content with first plain text message (if any)\n\t\tif (parsed.messages.length > 0) {\n\t\t\tinitialMessage = textContent + parsed.messages[0];\n\t\t\tparsed.messages.shift(); // Remove first message as it's been combined\n\t\t} else {\n\t\t\tinitialMessage = textContent;\n\t\t}\n\n\t\tinitialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined;\n\t}\n\n\t// Initialize theme (before any TUI rendering)\n\tconst settingsManager = new SettingsManager();\n\tconst themeName = settingsManager.getTheme();\n\tinitTheme(themeName);\n\n\t// Setup session manager\n\tconst sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);\n\n\t// Disable session saving if --no-session flag is set\n\tif (parsed.noSession) {\n\t\tsessionManager.disable();\n\t}\n\n\t// Handle --resume flag: show session selector\n\tif (parsed.resume) {\n\t\tconst selectedSession = await selectSession(sessionManager);\n\t\tif (!selectedSession) {\n\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\treturn;\n\t\t}\n\t\t// Set the selected session as the active session\n\t\tsessionManager.setSessionFile(selectedSession);\n\t}\n\n\t// Resolve model scope early if provided (needed for initial model selection)\n\tlet scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\tif (parsed.models && parsed.models.length > 0) {\n\t\tscopedModels = await resolveModelScope(parsed.models);\n\t}\n\n\t// Determine initial model using priority system:\n\t// 1. CLI args (--provider and --model)\n\t// 2. First model from --models scope\n\t// 3. Restored from session (if --continue or --resume)\n\t// 4. Saved default from settings.json\n\t// 5. First available model with valid API key\n\t// 6. null (allowed in interactive mode)\n\tlet initialModel: Model | null = null;\n\tlet initialThinking: ThinkingLevel = \"off\";\n\n\tif (parsed.provider && parsed.model) {\n\t\t// 1. CLI args take priority\n\t\tconst { model, error } = findModel(parsed.provider, parsed.model);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (!model) {\n\t\t\tconsole.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tinitialModel = model;\n\t} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {\n\t\t// 2. Use first model from --models scope (skip if continuing/resuming session)\n\t\tinitialModel = scopedModels[0].model;\n\t\tinitialThinking = scopedModels[0].thinkingLevel;\n\t} else if (parsed.continue || parsed.resume) {\n\t\t// 3. Restore from session (will be handled below after loading session)\n\t\t// Leave initialModel as null for now\n\t}\n\n\tif (!initialModel) {\n\t\t// 3. Try saved default from settings\n\t\tconst defaultProvider = settingsManager.getDefaultProvider();\n\t\tconst defaultModel = settingsManager.getDefaultModel();\n\t\tif (defaultProvider && defaultModel) {\n\t\t\tconst { model, error } = findModel(defaultProvider, defaultModel);\n\t\t\tif (error) {\n\t\t\t\tconsole.error(chalk.red(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t\tinitialModel = model;\n\n\t\t\t// Also load saved thinking level if we're using saved model\n\t\t\tconst savedThinking = settingsManager.getDefaultThinkingLevel();\n\t\t\tif (savedThinking) {\n\t\t\t\tinitialThinking = savedThinking;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!initialModel) {\n\t\t// 4. Try first available model with valid API key\n\t\t// Prefer default model for each provider if available\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tif (availableModels.length > 0) {\n\t\t\t// Try to find a default model from known providers\n\t\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\t\tconst defaultModelId = defaultModelPerProvider[provider];\n\t\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);\n\t\t\t\tif (match) {\n\t\t\t\t\tinitialModel = match;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If no default found, use first available\n\t\t\tif (!initialModel) {\n\t\t\t\tinitialModel = availableModels[0];\n\t\t\t}\n\t\t}\n\t}\n\n\t// Determine mode early to know if we should print messages and fail early\n\t// Interactive mode: no --print flag and no --mode flag\n\t// Having initial messages doesn't make it non-interactive anymore\n\tconst isInteractive = !parsed.print && parsed.mode === undefined;\n\tconst mode = parsed.mode || \"text\";\n\t// Only print informational messages in interactive mode\n\t// Non-interactive modes (-p, --mode json, --mode rpc) should be silent except for output\n\tconst shouldPrintMessages = isInteractive;\n\n\t// Non-interactive mode: fail early if no model available\n\tif (!isInteractive && !initialModel) {\n\t\tconsole.error(chalk.red(\"No models available.\"));\n\t\tconsole.error(chalk.yellow(\"\\nSet an API key environment variable:\"));\n\t\tconsole.error(\" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.\");\n\t\tconsole.error(chalk.yellow(`\\nOr create ${getModelsPath()}`));\n\t\tprocess.exit(1);\n\t}\n\n\t// Non-interactive mode: validate API key exists\n\tif (!isInteractive && initialModel) {\n\t\tconst apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));\n\t\tif (!apiKey) {\n\t\t\tconsole.error(chalk.red(`No API key found for ${initialModel.provider}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tconst systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);\n\n\t// Load previous messages if continuing or resuming\n\t// This may update initialModel if restoring from session\n\tif (parsed.continue || parsed.resume) {\n\t\t// Load and restore model (overrides initialModel if found and has API key)\n\t\tconst savedModel = sessionManager.loadModel();\n\t\tif (savedModel) {\n\t\t\tconst { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);\n\n\t\t\tif (error) {\n\t\t\t\tconsole.error(chalk.red(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\n\t\t\t// Check if restored model exists and has a valid API key\n\t\t\tconst hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;\n\n\t\t\tif (restoredModel && hasApiKey) {\n\t\t\t\tinitialModel = restoredModel;\n\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\tconsole.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Model not found or no API key - fall back to default selection\n\t\t\t\tconst reason = !restoredModel ? \"model no longer exists\" : \"no API key available\";\n\n\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\tchalk.yellow(\n\t\t\t\t\t\t\t`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Ensure we have a valid model - use the same fallback logic\n\t\t\t\tif (!initialModel) {\n\t\t\t\t\tconst { models: availableModels, error: availableError } = await getAvailableModels();\n\t\t\t\t\tif (availableError) {\n\t\t\t\t\t\tconsole.error(chalk.red(availableError));\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t\tif (availableModels.length > 0) {\n\t\t\t\t\t\t// Try to find a default model from known providers\n\t\t\t\t\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\t\t\t\t\tconst defaultModelId = defaultModelPerProvider[provider];\n\t\t\t\t\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);\n\t\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\t\tinitialModel = match;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If no default found, use first available\n\t\t\t\t\t\tif (!initialModel) {\n\t\t\t\t\t\t\tinitialModel = availableModels[0];\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (initialModel && shouldPrintMessages) {\n\t\t\t\t\t\t\tconsole.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// No models available at all\n\t\t\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\t\t\tconsole.error(chalk.red(\"\\nNo models available.\"));\n\t\t\t\t\t\t\tconsole.error(chalk.yellow(\"Set an API key environment variable:\"));\n\t\t\t\t\t\t\tconsole.error(\" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.\");\n\t\t\t\t\t\t\tconsole.error(chalk.yellow(`\\nOr create ${getModelsPath()}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t} else if (shouldPrintMessages) {\n\t\t\t\t\tconsole.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// CLI --thinking flag takes highest priority\n\tif (parsed.thinking) {\n\t\tinitialThinking = parsed.thinking;\n\t}\n\n\t// Determine which tools to use\n\tconst selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;\n\n\t// Create agent (initialModel can be null in interactive mode)\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: initialModel as any, // Can be null\n\t\t\tthinkingLevel: initialThinking,\n\t\t\ttools: selectedTools,\n\t\t},\n\t\tmessageTransformer,\n\t\tqueueMode: settingsManager.getQueueMode(),\n\t\ttransport: new ProviderTransport({\n\t\t\t// Dynamic API key lookup based on current model's provider\n\t\t\tgetApiKey: async () => {\n\t\t\t\tconst currentModel = agent.state.model;\n\t\t\t\tif (!currentModel) {\n\t\t\t\t\tthrow new Error(\"No model selected\");\n\t\t\t\t}\n\n\t\t\t\t// Try CLI override first\n\t\t\t\tif (parsed.apiKey) {\n\t\t\t\t\treturn parsed.apiKey;\n\t\t\t\t}\n\n\t\t\t\t// Use model-specific key lookup\n\t\t\t\tconst key = await getApiKeyForModel(currentModel);\n\t\t\t\tif (!key) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`No API key found for provider \"${currentModel.provider}\". Please set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn key;\n\t\t\t},\n\t\t}),\n\t});\n\n\t// If initial thinking was requested but model doesn't support it, silently reset to off\n\tif (initialThinking !== \"off\" && initialModel && !initialModel.reasoning) {\n\t\tagent.setThinkingLevel(\"off\");\n\t}\n\n\t// Track if we had to fall back from saved model (to show in chat later)\n\tlet modelFallbackMessage: string | null = null;\n\n\t// Load previous messages if continuing or resuming\n\tif (parsed.continue || parsed.resume) {\n\t\tconst messages = sessionManager.loadMessages();\n\t\tif (messages.length > 0) {\n\t\t\tagent.replaceMessages(messages);\n\t\t}\n\n\t\t// Load and restore thinking level\n\t\tconst thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;\n\t\tif (thinkingLevel) {\n\t\t\tagent.setThinkingLevel(thinkingLevel);\n\t\t\tif (shouldPrintMessages) {\n\t\t\t\tconsole.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));\n\t\t\t}\n\t\t}\n\n\t\t// Check if we had to fall back from saved model\n\t\tconst savedModel = sessionManager.loadModel();\n\t\tif (savedModel && initialModel) {\n\t\t\tconst savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId;\n\t\t\tif (!savedMatches) {\n\t\t\t\tconst { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);\n\t\t\t\tif (error) {\n\t\t\t\t\t// Config error - already shown above, just use generic message\n\t\t\t\t\tmodelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`;\n\t\t\t\t} else {\n\t\t\t\t\tconst reason = !restoredModel ? \"model no longer exists\" : \"no API key available\";\n\t\t\t\t\tmodelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Log loaded context files (they're already in the system prompt)\n\tif (shouldPrintMessages && !parsed.continue && !parsed.resume) {\n\t\tconst contextFiles = loadProjectContextFiles();\n\t\tif (contextFiles.length > 0) {\n\t\t\tconsole.log(chalk.dim(\"Loaded project context from:\"));\n\t\t\tfor (const { path: filePath } of contextFiles) {\n\t\t\t\tconsole.log(chalk.dim(` - ${filePath}`));\n\t\t\t}\n\t\t}\n\t}\n\n\t// Route to appropriate mode\n\tif (mode === \"rpc\") {\n\t\t// RPC mode - headless operation\n\t\tawait runRpcMode(agent, sessionManager, settingsManager);\n\t} else if (isInteractive) {\n\t\t// Check for new version in the background (don't block startup)\n\t\tconst versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);\n\n\t\t// Check if we should show changelog (only in interactive mode, only for new sessions)\n\t\tlet changelogMarkdown: string | null = null;\n\t\tif (!parsed.continue && !parsed.resume) {\n\t\t\tconst lastVersion = settingsManager.getLastChangelogVersion();\n\n\t\t\t// Check if we need to show changelog\n\t\t\tif (!lastVersion) {\n\t\t\t\t// First run - show all entries\n\t\t\t\tconst changelogPath = getChangelogPath();\n\t\t\t\tconst entries = parseChangelog(changelogPath);\n\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\tchangelogMarkdown = entries.map((e) => e.content).join(\"\\n\\n\");\n\t\t\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Parse current and last versions\n\t\t\t\tconst changelogPath = getChangelogPath();\n\t\t\t\tconst entries = parseChangelog(changelogPath);\n\t\t\t\tconst newEntries = getNewEntries(entries, lastVersion);\n\n\t\t\t\tif (newEntries.length > 0) {\n\t\t\t\t\tchangelogMarkdown = newEntries.map((e) => e.content).join(\"\\n\\n\");\n\t\t\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Show model scope if provided\n\t\tif (scopedModels.length > 0) {\n\t\t\tconst modelList = scopedModels\n\t\t\t\t.map((sm) => {\n\t\t\t\t\tconst thinkingStr = sm.thinkingLevel !== \"off\" ? `:${sm.thinkingLevel}` : \"\";\n\t\t\t\t\treturn `${sm.model.id}${thinkingStr}`;\n\t\t\t\t})\n\t\t\t\t.join(\", \");\n\t\t\tconsole.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray(\"(Ctrl+P to cycle)\")}`));\n\t\t}\n\n\t\t// Ensure fd tool is available for file autocomplete\n\t\tconst fdPath = await ensureTool(\"fd\");\n\n\t\t// Interactive mode - use TUI (may have initial messages from CLI args)\n\t\tconst collapseChangelog = settingsManager.getCollapseChangelog();\n\t\tawait runInteractiveMode(\n\t\t\tagent,\n\t\t\tsessionManager,\n\t\t\tsettingsManager,\n\t\t\tVERSION,\n\t\t\tchangelogMarkdown,\n\t\t\tcollapseChangelog,\n\t\t\tmodelFallbackMessage,\n\t\t\tversionCheckPromise,\n\t\t\tscopedModels,\n\t\t\tparsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialAttachments,\n\t\t\tfdPath,\n\t\t);\n\t} else {\n\t\t// Non-interactive mode (--print flag or --mode flag)\n\t\tawait runSingleShotMode(agent, sessionManager, parsed.messages, mode, initialMessage, initialAttachments);\n\t}\n}\n"}],"isError":false,"timestamp":1765233669393}}
-{"type":"message","timestamp":"2025-12-08T22:41:09.403Z","message":{"role":"toolResult","toolCallId":"toolu_018AGG1WjGWVfUR2Sibzkh2Q","toolName":"read","content":[{"type":"text","text":"import { randomBytes } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport { createWriteStream, type WriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport * as path from \"node:path\";\nimport { join } from \"node:path\";\nimport type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, Model } from \"@mariozechner/pi-ai\";\nimport type { SlashCommand } from \"@mariozechner/pi-tui\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tInput,\n\tLoader,\n\tMarkdown,\n\tProcessTerminal,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\tTUI,\n\tvisibleWidth,\n} from \"@mariozechner/pi-tui\";\nimport { exec, spawn } from \"child_process\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, parseChangelog } from \"../changelog.js\";\nimport { copyToClipboard } from \"../clipboard.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"../compaction.js\";\nimport { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from \"../config.js\";\nimport { exportSessionToHtml } from \"../export-html.js\";\nimport { type BashExecutionMessage, isBashExecutionMessage } from \"../messages.js\";\nimport { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from \"../model-config.js\";\nimport { listOAuthProviders, login, logout, type SupportedOAuthProvider } from \"../oauth/index.js\";\nimport {\n\tgetLatestCompactionEntry,\n\tloadSessionFromEntries,\n\ttype SessionManager,\n\tSUMMARY_PREFIX,\n\tSUMMARY_SUFFIX,\n} from \"../session-manager.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { getShellConfig, killProcessTree, sanitizeBinaryOutput } from \"../shell.js\";\nimport { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from \"../slash-commands.js\";\nimport { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from \"../theme/theme.js\";\nimport { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from \"../tools/truncate.js\";\nimport { AssistantMessageComponent } from \"./assistant-message.js\";\nimport { BashExecutionComponent } from \"./bash-execution.js\";\nimport { CompactionComponent } from \"./compaction.js\";\nimport { CustomEditor } from \"./custom-editor.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { FooterComponent } from \"./footer.js\";\nimport { ModelSelectorComponent } from \"./model-selector.js\";\nimport { OAuthSelectorComponent } from \"./oauth-selector.js\";\nimport { QueueModeSelectorComponent } from \"./queue-mode-selector.js\";\nimport { SessionSelectorComponent } from \"./session-selector.js\";\nimport { ThemeSelectorComponent } from \"./theme-selector.js\";\nimport { ThinkingSelectorComponent } from \"./thinking-selector.js\";\nimport { ToolExecutionComponent } from \"./tool-execution.js\";\nimport { UserMessageComponent } from \"./user-message.js\";\nimport { UserMessageSelectorComponent } from \"./user-message-selector.js\";\n\n/**\n * TUI renderer for the coding agent\n */\nexport class TuiRenderer {\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate editor: CustomEditor;\n\tprivate editorContainer: Container; // Container to swap between editor and selector\n\tprivate footer: FooterComponent;\n\tprivate agent: Agent;\n\tprivate sessionManager: SessionManager;\n\tprivate settingsManager: SettingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | null = null;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | null = null;\n\tprivate collapseChangelog = false;\n\n\t// Message queueing\n\tprivate queuedMessages: string[] = [];\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | null = null;\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map();\n\n\t// Thinking level selector\n\tprivate thinkingSelector: ThinkingSelectorComponent | null = null;\n\n\t// Queue mode selector\n\tprivate queueModeSelector: QueueModeSelectorComponent | null = null;\n\n\t// Theme selector\n\tprivate themeSelector: ThemeSelectorComponent | null = null;\n\n\t// Model selector\n\tprivate modelSelector: ModelSelectorComponent | null = null;\n\n\t// User message selector (for branching)\n\tprivate userMessageSelector: UserMessageSelectorComponent | null = null;\n\n\t// Session selector (for resume)\n\tprivate sessionSelector: SessionSelectorComponent | null = null;\n\n\t// OAuth selector\n\tprivate oauthSelector: any | null = null;\n\n\t// Track if this is the first user message (to skip spacer)\n\tprivate isFirstUserMessage = true;\n\n\t// Model scope for quick cycling\n\tprivate scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\n\t// File-based slash commands\n\tprivate fileCommands: FileSlashCommand[] = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Track running bash command process for cancellation\n\tprivate bashProcess: ReturnType | null = null;\n\n\t// Track current bash execution component\n\tprivate bashComponent: BashExecutionComponent | null = null;\n\n\tconstructor(\n\t\tagent: Agent,\n\t\tsessionManager: SessionManager,\n\t\tsettingsManager: SettingsManager,\n\t\tversion: string,\n\t\tchangelogMarkdown: string | null = null,\n\t\tcollapseChangelog = false,\n\t\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\t\tfdPath: string | null = null,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.sessionManager = sessionManager;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.version = version;\n\t\tthis.changelogMarkdown = changelogMarkdown;\n\t\tthis.collapseChangelog = collapseChangelog;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.editor = new CustomEditor(getEditorTheme());\n\t\tthis.editorContainer = new Container(); // Container to hold editor or selector\n\t\tthis.editorContainer.addChild(this.editor); // Start with editor\n\t\tthis.footer = new FooterComponent(agent.state);\n\t\tthis.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());\n\n\t\t// Define slash commands\n\t\tconst thinkingCommand: SlashCommand = {\n\t\t\tname: \"thinking\",\n\t\t\tdescription: \"Select reasoning level (opens selector UI)\",\n\t\t};\n\n\t\tconst modelCommand: SlashCommand = {\n\t\t\tname: \"model\",\n\t\t\tdescription: \"Select model (opens selector UI)\",\n\t\t};\n\n\t\tconst exportCommand: SlashCommand = {\n\t\t\tname: \"export\",\n\t\t\tdescription: \"Export session to HTML file\",\n\t\t};\n\n\t\tconst copyCommand: SlashCommand = {\n\t\t\tname: \"copy\",\n\t\t\tdescription: \"Copy last agent message to clipboard\",\n\t\t};\n\n\t\tconst sessionCommand: SlashCommand = {\n\t\t\tname: \"session\",\n\t\t\tdescription: \"Show session info and stats\",\n\t\t};\n\n\t\tconst changelogCommand: SlashCommand = {\n\t\t\tname: \"changelog\",\n\t\t\tdescription: \"Show changelog entries\",\n\t\t};\n\n\t\tconst branchCommand: SlashCommand = {\n\t\t\tname: \"branch\",\n\t\t\tdescription: \"Create a new branch from a previous message\",\n\t\t};\n\n\t\tconst loginCommand: SlashCommand = {\n\t\t\tname: \"login\",\n\t\t\tdescription: \"Login with OAuth provider\",\n\t\t};\n\n\t\tconst logoutCommand: SlashCommand = {\n\t\t\tname: \"logout\",\n\t\t\tdescription: \"Logout from OAuth provider\",\n\t\t};\n\n\t\tconst queueCommand: SlashCommand = {\n\t\t\tname: \"queue\",\n\t\t\tdescription: \"Select message queue mode (opens selector UI)\",\n\t\t};\n\n\t\tconst themeCommand: SlashCommand = {\n\t\t\tname: \"theme\",\n\t\t\tdescription: \"Select color theme (opens selector UI)\",\n\t\t};\n\n\t\tconst clearCommand: SlashCommand = {\n\t\t\tname: \"clear\",\n\t\t\tdescription: \"Clear context and start a fresh session\",\n\t\t};\n\n\t\tconst compactCommand: SlashCommand = {\n\t\t\tname: \"compact\",\n\t\t\tdescription: \"Manually compact the session context\",\n\t\t};\n\n\t\tconst autocompactCommand: SlashCommand = {\n\t\t\tname: \"autocompact\",\n\t\t\tdescription: \"Toggle automatic context compaction\",\n\t\t};\n\n\t\tconst resumeCommand: SlashCommand = {\n\t\t\tname: \"resume\",\n\t\t\tdescription: \"Resume a different session\",\n\t\t};\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = settingsManager.getHideThinkingBlock();\n\n\t\t// Load file-based slash commands\n\t\tthis.fileCommands = loadSlashCommands();\n\n\t\t// Convert file commands to SlashCommand format\n\t\tconst fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: cmd.description,\n\t\t}));\n\n\t\t// Setup autocomplete for file paths and slash commands\n\t\tconst autocompleteProvider = new CombinedAutocompleteProvider(\n\t\t\t[\n\t\t\t\tthinkingCommand,\n\t\t\t\tmodelCommand,\n\t\t\t\tthemeCommand,\n\t\t\t\texportCommand,\n\t\t\t\tcopyCommand,\n\t\t\t\tsessionCommand,\n\t\t\t\tchangelogCommand,\n\t\t\t\tbranchCommand,\n\t\t\t\tloginCommand,\n\t\t\t\tlogoutCommand,\n\t\t\t\tqueueCommand,\n\t\t\t\tclearCommand,\n\t\t\t\tcompactCommand,\n\t\t\t\tautocompactCommand,\n\t\t\t\tresumeCommand,\n\t\t\t\t...fileSlashCommands,\n\t\t\t],\n\t\t\tprocess.cwd(),\n\t\t\tfdPath,\n\t\t);\n\t\tthis.editor.setAutocompleteProvider(autocompleteProvider);\n\t}\n\n\tasync init(): Promise {\n\t\tif (this.isInitialized) return;\n\n\t\t// Add header with logo and instructions\n\t\tconst logo = theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\t\tconst instructions =\n\t\t\ttheme.fg(\"dim\", \"esc\") +\n\t\t\ttheme.fg(\"muted\", \" to interrupt\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c\") +\n\t\t\ttheme.fg(\"muted\", \" to clear\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c twice\") +\n\t\t\ttheme.fg(\"muted\", \" to exit\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+k\") +\n\t\t\ttheme.fg(\"muted\", \" to delete line\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"shift+tab\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+p\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle models\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+o\") +\n\t\t\ttheme.fg(\"muted\", \" to expand tools\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+t\") +\n\t\t\ttheme.fg(\"muted\", \" to toggle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"/\") +\n\t\t\ttheme.fg(\"muted\", \" for commands\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"!\") +\n\t\t\ttheme.fg(\"muted\", \" to run bash\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"drop files\") +\n\t\t\ttheme.fg(\"muted\", \" to attach\");\n\t\tconst header = new Text(logo + \"\\n\" + instructions, 1, 0);\n\n\t\t// Setup UI layout\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(header);\n\t\tthis.ui.addChild(new Spacer(1));\n\n\t\t// Add changelog if provided\n\t\tif (this.changelogMarkdown) {\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t\tif (this.collapseChangelog) {\n\t\t\t\t// Show condensed version with hint to use /changelog\n\t\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\t\tthis.ui.addChild(new Text(condensedText, 1, 0));\n\t\t\t} else {\n\t\t\t\tthis.ui.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t\tthis.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t}\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(this.editorContainer); // Use container that can hold editor or selector\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\t// Set up custom key handlers on the editor\n\t\tthis.editor.onEscape = () => {\n\t\t\t// Intercept Escape key when processing\n\t\t\tif (this.loadingAnimation) {\n\t\t\t\t// Get all queued messages\n\t\t\t\tconst queuedText = this.queuedMessages.join(\"\\n\\n\");\n\n\t\t\t\t// Get current editor text\n\t\t\t\tconst currentText = this.editor.getText();\n\n\t\t\t\t// Combine: queued messages + current editor text\n\t\t\t\tconst combinedText = [queuedText, currentText].filter((t) => t.trim()).join(\"\\n\\n\");\n\n\t\t\t\t// Put back in editor\n\t\t\t\tthis.editor.setText(combinedText);\n\n\t\t\t\t// Clear queued messages\n\t\t\t\tthis.queuedMessages = [];\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Clear agent's queue too\n\t\t\t\tthis.agent.clearMessageQueue();\n\n\t\t\t\t// Abort\n\t\t\t\tthis.agent.abort();\n\t\t\t} else if (this.bashProcess) {\n\t\t\t\t// Kill running bash command\n\t\t\t\tif (this.bashProcess.pid) {\n\t\t\t\t\tkillProcessTree(this.bashProcess.pid);\n\t\t\t\t}\n\t\t\t\tthis.bashProcess = null;\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\t// Cancel bash mode and clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /branch\n\t\t\t\tconst now = Date.now();\n\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\tthis.lastEscapeTime = 0; // Reset to prevent triple-escape\n\t\t\t\t} else {\n\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tthis.editor.onCtrlC = () => {\n\t\t\tthis.handleCtrlC();\n\t\t};\n\n\t\tthis.editor.onShiftTab = () => {\n\t\t\tthis.cycleThinkingLevel();\n\t\t};\n\n\t\tthis.editor.onCtrlP = () => {\n\t\t\tthis.cycleModel();\n\t\t};\n\n\t\tthis.editor.onCtrlO = () => {\n\t\t\tthis.toggleToolOutputExpansion();\n\t\t};\n\n\t\tthis.editor.onCtrlT = () => {\n\t\t\tthis.toggleThinkingBlockVisibility();\n\t\t};\n\n\t\t// Handle editor text changes for bash mode detection\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\n\t\t// Handle editor submission\n\t\tthis.editor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Check for /thinking command\n\t\t\tif (text === \"/thinking\") {\n\t\t\t\t// Show thinking level selector\n\t\t\t\tthis.showThinkingSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /model command\n\t\t\tif (text === \"/model\") {\n\t\t\t\t// Show model selector\n\t\t\t\tthis.showModelSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /export command\n\t\t\tif (text.startsWith(\"/export\")) {\n\t\t\t\tthis.handleExportCommand(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /copy command\n\t\t\tif (text === \"/copy\") {\n\t\t\t\tthis.handleCopyCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /session command\n\t\t\tif (text === \"/session\") {\n\t\t\t\tthis.handleSessionCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /changelog command\n\t\t\tif (text === \"/changelog\") {\n\t\t\t\tthis.handleChangelogCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /branch command\n\t\t\tif (text === \"/branch\") {\n\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /login command\n\t\t\tif (text === \"/login\") {\n\t\t\t\tthis.showOAuthSelector(\"login\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /logout command\n\t\t\tif (text === \"/logout\") {\n\t\t\t\tthis.showOAuthSelector(\"logout\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /queue command\n\t\t\tif (text === \"/queue\") {\n\t\t\t\tthis.showQueueModeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /theme command\n\t\t\tif (text === \"/theme\") {\n\t\t\t\tthis.showThemeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /clear command\n\t\t\tif (text === \"/clear\") {\n\t\t\t\tthis.handleClearCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /compact command\n\t\t\tif (text === \"/compact\" || text.startsWith(\"/compact \")) {\n\t\t\t\tconst customInstructions = text.startsWith(\"/compact \") ? text.slice(9).trim() : undefined;\n\t\t\t\tthis.handleCompactCommand(customInstructions);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /autocompact command\n\t\t\tif (text === \"/autocompact\") {\n\t\t\t\tthis.handleAutocompactCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /debug command\n\t\t\tif (text === \"/debug\") {\n\t\t\t\tthis.handleDebugCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /resume command\n\t\t\tif (text === \"/resume\") {\n\t\t\t\tthis.showSessionSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for bash command (!)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst command = text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\t// Block if bash already running\n\t\t\t\t\tif (this.bashProcess) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\t// Restore text since editor clears on submit\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\t\tthis.editor.addToHistory(text);\n\t\t\t\t\tthis.handleBashCommand(command);\n\t\t\t\t\t// Reset bash mode since editor is now empty\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for file-based slash commands\n\t\t\ttext = expandSlashCommand(text, this.fileCommands);\n\n\t\t\t// Normal message submission - validate model and API key first\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tif (!currentModel) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t\"No model selected.\\n\\n\" +\n\t\t\t\t\t\t\"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\\n\" +\n\t\t\t\t\t\t`or create ${getModelsPath()}\\n\\n` +\n\t\t\t\t\t\t\"Then use /model to select a model.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Validate API key (async)\n\t\t\tconst apiKey = await getApiKeyForModel(currentModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t`No API key found for ${currentModel.provider}.\\n\\n` +\n\t\t\t\t\t\t`Set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t);\n\t\t\t\tthis.editor.setText(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check if agent is currently streaming\n\t\t\tif (this.agent.state.isStreaming) {\n\t\t\t\t// Queue the message instead of submitting\n\t\t\t\tthis.queuedMessages.push(text);\n\n\t\t\t\t// Queue in agent\n\t\t\t\tawait this.agent.queueMessage({\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\n\t\t\t\t// Update pending messages display\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\tthis.editor.addToHistory(text);\n\n\t\t\t\t// Clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// All good, proceed with submission\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\n\t\t\t// Add to history for up/down arrow navigation\n\t\t\tthis.editor.addToHistory(text);\n\t\t};\n\n\t\t// Start the UI\n\t\tthis.ui.start();\n\t\tthis.isInitialized = true;\n\n\t\t// Subscribe to agent events for UI updates and session saving\n\t\tthis.subscribeToAgent();\n\n\t\t// Set up theme file watcher for live reload\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher\n\t\tthis.footer.watchBranch(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.agent.subscribe(async (event) => {\n\t\t\t// Handle UI updates\n\t\t\tawait this.handleEvent(event, this.agent.state);\n\n\t\t\t// Save messages to session\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tthis.sessionManager.saveMessage(event.message);\n\n\t\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\t\tif (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {\n\t\t\t\t\tthis.sessionManager.startSession(this.agent.state);\n\t\t\t\t}\n\n\t\t\t\t// Check for auto-compaction after assistant messages\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\tawait this.checkAutoCompaction();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate async checkAutoCompaction(): Promise {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message from agent state\n\t\tconst messages = this.agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = this.agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tawait this.executeCompaction(undefined, true);\n\t}\n\n\tprivate async handleEvent(event: AgentEvent, state: AgentState): Promise {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\t// Update footer with current stats\n\t\tthis.footer.updateState(state);\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\t// Show loading animation\n\t\t\t\t// Note: Don't disable submit - we handle queuing in onSubmit callback\n\t\t\t\t// Stop old loader before clearing\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t}\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.loadingAnimation = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\"Working... (esc to interrupt)\",\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\t// Check if this is a queued message\n\t\t\t\t\tconst userMsg = event.message;\n\t\t\t\t\tconst textBlocks =\n\t\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\t\tconst messageText = textBlocks.map((c) => c.text).join(\"\");\n\n\t\t\t\t\tconst queuedIndex = this.queuedMessages.indexOf(messageText);\n\t\t\t\t\tif (queuedIndex !== -1) {\n\t\t\t\t\t\t// Remove from queued messages\n\t\t\t\t\t\tthis.queuedMessages.splice(queuedIndex, 1);\n\t\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Show user message immediately and clear editor\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\t// Create assistant component for streaming\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(event.message as AssistantMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\t// Update streaming component\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// Create tool execution components as soon as we see tool calls\n\t\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\t// Only create if we haven't created it yet\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(\"\", 0, 0));\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// Update existing component with latest arguments as they stream\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\t// Skip user messages (already shown in message_start)\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\n\t\t\t\t\t// Update streaming component with final message (includes stopReason)\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// If message was aborted or errored, mark all pending tool components as failed\n\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\" ? \"Operation aborted\" : assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\tfor (const [toolCallId, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Keep the streaming component - it's now the final assistant message\n\t\t\t\t\tthis.streamingComponent = null;\n\n\t\t\t\t\t// Invalidate footer cache to refresh git branch (in case agent executed git commands)\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\t// Component should already exist from message_update, but create if missing\n\t\t\t\tif (!this.pendingTools.has(event.toolCallId)) {\n\t\t\t\t\tconst component = new ToolExecutionComponent(event.toolName, event.args);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Update the existing tool component with the result\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\t// Convert result to the format expected by updateResult\n\t\t\t\t\tconst resultData =\n\t\t\t\t\t\ttypeof event.result === \"string\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: event.result }],\n\t\t\t\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\tcontent: event.result.content,\n\t\t\t\t\t\t\t\t\tdetails: event.result.details,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\tcomponent.updateResult(resultData);\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\t// Stop loading animation\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = null;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = null;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// Note: Don't need to re-enable submit - we never disable it\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate addMessageToChat(message: Message | AppMessage): void {\n\t\t// Handle bash execution messages\n\t\tif (isBashExecutionMessage(message)) {\n\t\t\tconst bashMsg = message as BashExecutionMessage;\n\t\t\tconst component = new BashExecutionComponent(bashMsg.command, this.ui);\n\t\t\tif (bashMsg.output) {\n\t\t\t\tcomponent.appendOutput(bashMsg.output);\n\t\t\t}\n\t\t\tcomponent.setComplete(\n\t\t\t\tbashMsg.exitCode,\n\t\t\t\tbashMsg.cancelled,\n\t\t\t\tbashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\tbashMsg.fullOutputPath,\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(component);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.role === \"user\") {\n\t\t\tconst userMsg = message;\n\t\t\t// Extract text content from content blocks\n\t\t\tconst textBlocks =\n\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\tif (textContent) {\n\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t}\n\t\t} else if (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message;\n\n\t\t\t// Add assistant message component\n\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t}\n\t\t// Note: tool calls and results are now handled via tool_execution_start/end events\n\t}\n\n\trenderInitialMessages(state: AgentState): void {\n\t\t// Render all existing messages (for --continue mode)\n\t\t// Reset first user message flag for initial render\n\t\tthis.isFirstUserMessage = true;\n\n\t\t// Update footer with loaded state\n\t\tthis.footer.updateState(state);\n\n\t\t// Update editor border color based on current thinking level\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\t// Render messages\n\t\tfor (let i = 0; i < state.messages.length; i++) {\n\t\t\tconst message = state.messages[i];\n\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\t// Create tool execution components for any tool calls\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\t// If message was aborted/errored, immediately mark tool as failed\n\t\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\"\n\t\t\t\t\t\t\t\t\t? \"Operation aborted\"\n\t\t\t\t\t\t\t\t\t: assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Store in map so we can update with results later\n\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Update existing tool execution component with results\t\t\t\t;\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\t// Remove from pending map since it's complete\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Clear pending tools after rendering initial messages\n\t\tthis.pendingTools.clear();\n\n\t\t// Populate editor history with user messages from the session (oldest first so newest is at index 0)\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof message.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t\t\t: message.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\t// Skip compaction summary messages\n\t\t\t\tif (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {\n\t\t\t\t\tthis.editor.addToHistory(textContent);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tasync getUserInput(): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\t// Reset state and re-render messages from agent state\n\t\tthis.isFirstUserMessage = true;\n\t\tthis.pendingTools.clear();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\tfor (const message of this.agent.state.messages) {\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.pendingTools.clear();\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleCtrlC(): void {\n\t\t// Handle Ctrl+C double-press logic\n\t\tconst now = Date.now();\n\t\tconst timeSinceLastCtrlC = now - this.lastSigintTime;\n\n\t\tif (timeSinceLastCtrlC < 500) {\n\t\t\t// Second Ctrl+C within 500ms - exit\n\t\t\tthis.stop();\n\t\t\tprocess.exit(0);\n\t\t} else {\n\t\t\t// First Ctrl+C - clear the editor\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.agent.state.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\t// Only cycle if model supports thinking\n\t\tif (!this.agent.state.model?.reasoning) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Current model does not support thinking\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// xhigh is only available for codex-max models\n\t\tconst modelId = this.agent.state.model?.id || \"\";\n\t\tconst supportsXhigh = modelId.includes(\"codex-max\");\n\t\tconst levels: ThinkingLevel[] = supportsXhigh\n\t\t\t? [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]\n\t\t\t: [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\t\tconst currentLevel = this.agent.state.thinkingLevel || \"off\";\n\t\tconst currentIndex = levels.indexOf(currentLevel);\n\t\tconst nextIndex = (currentIndex + 1) % levels.length;\n\t\tconst nextLevel = levels[nextIndex];\n\n\t\t// Apply the new thinking level\n\t\tthis.agent.setThinkingLevel(nextLevel);\n\n\t\t// Save thinking level change to session and settings\n\t\tthis.sessionManager.saveThinkingLevelChange(nextLevel);\n\t\tthis.settingsManager.setDefaultThinkingLevel(nextLevel);\n\n\t\t// Update border color\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Show brief notification\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking level: ${nextLevel}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async cycleModel(): Promise {\n\t\t// Use scoped models if available, otherwise all available models\n\t\tif (this.scopedModels.length > 0) {\n\t\t\t// Use scoped models with thinking levels\n\t\t\tif (this.scopedModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model in scope\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = this.scopedModels.findIndex(\n\t\t\t\t(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % this.scopedModels.length;\n\t\t\tconst nextEntry = this.scopedModels[nextIndex];\n\t\t\tconst nextModel = nextEntry.model;\n\t\t\tconst nextThinking = nextEntry.thinkingLevel;\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Apply thinking level (silently use \"off\" if model doesn't support thinking)\n\t\t\tconst effectiveThinking = nextModel.reasoning ? nextThinking : \"off\";\n\t\t\tthis.agent.setThinkingLevel(effectiveThinking);\n\t\t\tthis.sessionManager.saveThinkingLevelChange(effectiveThinking);\n\t\t\tthis.settingsManager.setDefaultThinkingLevel(effectiveThinking);\n\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tconst thinkingStr = nextModel.reasoning && nextThinking !== \"off\" ? ` (thinking: ${nextThinking})` : \"\";\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),\n\t\t\t);\n\t\t\tthis.ui.requestRender();\n\t\t} else {\n\t\t\t// Fallback to all available models (no thinking level changes)\n\t\t\tconst { models: availableModels, error } = await getAvailableModels();\n\t\t\tif (error) {\n\t\t\t\tthis.showError(`Failed to load models: ${error}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 0) {\n\t\t\t\tthis.showError(\"No models available to cycle\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model available\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = availableModels.findIndex(\n\t\t\t\t(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % availableModels.length;\n\t\t\tconst nextModel = availableModels[nextIndex];\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool execution, compaction, and bash execution components\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Update all assistant message components and rebuild their content\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\t}\n\t\t}\n\n\t\t// Rebuild chat to apply visibility change\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// Show brief notification\n\t\tconst status = this.hideThinkingBlock ? \"hidden\" : \"visible\";\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking blocks: ${status}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\t// Show error message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\t// Show warning message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"warning\", `Warning: ${warningMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\t// Show new version notification in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\ttheme.bold(theme.fg(\"warning\", \"Update Available\")) +\n\t\t\t\t\t\"\\n\" +\n\t\t\t\t\ttheme.fg(\"muted\", `New version ${newVersion} is available. Run: `) +\n\t\t\t\t\ttheme.fg(\"accent\", \"npm install -g @mariozechner/pi-coding-agent\"),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showThinkingSelector(): void {\n\t\t// Create thinking selector with current level\n\t\tthis.thinkingSelector = new ThinkingSelectorComponent(\n\t\t\tthis.agent.state.thinkingLevel,\n\t\t\t(level) => {\n\t\t\t\t// Apply the selected thinking level\n\t\t\t\tthis.agent.setThinkingLevel(level);\n\n\t\t\t\t// Save thinking level change to session and settings\n\t\t\t\tthis.sessionManager.saveThinkingLevelChange(level);\n\t\t\t\tthis.settingsManager.setDefaultThinkingLevel(level);\n\n\t\t\t\t// Update border color\n\t\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Thinking level: ${level}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.thinkingSelector);\n\t\tthis.ui.setFocus(this.thinkingSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThinkingSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.thinkingSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showQueueModeSelector(): void {\n\t\t// Create queue mode selector with current mode\n\t\tthis.queueModeSelector = new QueueModeSelectorComponent(\n\t\t\tthis.agent.getQueueMode(),\n\t\t\t(mode) => {\n\t\t\t\t// Apply the selected queue mode\n\t\t\t\tthis.agent.setQueueMode(mode);\n\n\t\t\t\t// Save queue mode to settings\n\t\t\t\tthis.settingsManager.setQueueMode(mode);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Queue mode: ${mode}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.queueModeSelector);\n\t\tthis.ui.setFocus(this.queueModeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideQueueModeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.queueModeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showThemeSelector(): void {\n\t\t// Get current theme from settings\n\t\tconst currentTheme = this.settingsManager.getTheme() || \"dark\";\n\n\t\t// Create theme selector\n\t\tthis.themeSelector = new ThemeSelectorComponent(\n\t\t\tcurrentTheme,\n\t\t\t(themeName) => {\n\t\t\t\t// Apply the selected theme\n\t\t\t\tconst result = setTheme(themeName);\n\n\t\t\t\t// Save theme to settings\n\t\t\t\tthis.settingsManager.setTheme(themeName);\n\n\t\t\t\t// Invalidate all components to clear cached rendering\n\t\t\t\tthis.ui.invalidate();\n\n\t\t\t\t// Show confirmation or error message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tif (result.success) {\n\t\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Theme: ${themeName}`), 1, 0);\n\t\t\t\t\tthis.chatContainer.addChild(confirmText);\n\t\t\t\t} else {\n\t\t\t\t\tconst errorText = new Text(\n\t\t\t\t\t\ttheme.fg(\"error\", `Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`),\n\t\t\t\t\t\t1,\n\t\t\t\t\t\t0,\n\t\t\t\t\t);\n\t\t\t\t\tthis.chatContainer.addChild(errorText);\n\t\t\t\t}\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t(themeName) => {\n\t\t\t\t// Preview theme on selection change\n\t\t\t\tconst result = setTheme(themeName);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\t// If failed, theme already fell back to dark, just don't re-render\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.themeSelector);\n\t\tthis.ui.setFocus(this.themeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThemeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.themeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showModelSelector(): void {\n\t\t// Create model selector with current model\n\t\tthis.modelSelector = new ModelSelectorComponent(\n\t\t\tthis.ui,\n\t\t\tthis.agent.state.model,\n\t\t\tthis.settingsManager,\n\t\t\t(model) => {\n\t\t\t\t// Apply the selected model\n\t\t\t\tthis.agent.setModel(model);\n\n\t\t\t\t// Save model change to session\n\t\t\t\tthis.sessionManager.saveModelChange(model.provider, model.id);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Model: ${model.id}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.modelSelector);\n\t\tthis.ui.setFocus(this.modelSelector);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideModelSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.modelSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\t// Read from session file directly to see ALL historical user messages\n\t\t// (including those before compaction events)\n\t\tconst entries = this.sessionManager.loadEntries();\n\t\tconst userMessages: Array<{ index: number; text: string }> = [];\n\n\t\tconst getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {\n\t\t\tif (typeof content === \"string\") return content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\treturn content\n\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t.join(\"\");\n\t\t\t}\n\t\t\treturn \"\";\n\t\t};\n\n\t\tfor (let i = 0; i < entries.length; i++) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tif (entry.message.role !== \"user\") continue;\n\n\t\t\tconst textContent = getUserMessageText(entry.message.content);\n\t\t\tif (textContent) {\n\t\t\t\tuserMessages.push({ index: i, text: textContent });\n\t\t\t}\n\t\t}\n\n\t\t// Don't show selector if there are no messages or only one message\n\t\tif (userMessages.length <= 1) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No messages to branch from\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// Create user message selector\n\t\tthis.userMessageSelector = new UserMessageSelectorComponent(\n\t\t\tuserMessages,\n\t\t\t(entryIndex) => {\n\t\t\t\t// Get the selected user message text to put in the editor\n\t\t\t\tconst selectedEntry = entries[entryIndex];\n\t\t\t\tif (selectedEntry.type !== \"message\") return;\n\t\t\t\tif (selectedEntry.message.role !== \"user\") return;\n\n\t\t\t\tconst selectedText = getUserMessageText(selectedEntry.message.content);\n\n\t\t\t\t// Create a branched session by copying entries up to (but not including) the selected entry\n\t\t\t\tconst newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);\n\n\t\t\t\t// Set the new session file as active\n\t\t\t\tthis.sessionManager.setSessionFile(newSessionFile);\n\n\t\t\t\t// Reload the session\n\t\t\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\t\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t\t\t// Clear and re-render the chat\n\t\t\t\tthis.chatContainer.clear();\n\t\t\t\tthis.isFirstUserMessage = true;\n\t\t\t\tthis.renderInitialMessages(this.agent.state);\n\n\t\t\t\t// Show confirmation message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Branched to new session\"), 1, 0));\n\n\t\t\t\t// Put the selected message in the editor\n\n[Showing lines 1-1603 of 2377 (50.0KB limit). Use offset=1604 to continue]"}],"details":{"truncation":{"content":"import { randomBytes } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport { createWriteStream, type WriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport * as path from \"node:path\";\nimport { join } from \"node:path\";\nimport type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport type { AssistantMessage, Message, Model } from \"@mariozechner/pi-ai\";\nimport type { SlashCommand } from \"@mariozechner/pi-tui\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tInput,\n\tLoader,\n\tMarkdown,\n\tProcessTerminal,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\tTUI,\n\tvisibleWidth,\n} from \"@mariozechner/pi-tui\";\nimport { exec, spawn } from \"child_process\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, parseChangelog } from \"../changelog.js\";\nimport { copyToClipboard } from \"../clipboard.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"../compaction.js\";\nimport { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from \"../config.js\";\nimport { exportSessionToHtml } from \"../export-html.js\";\nimport { type BashExecutionMessage, isBashExecutionMessage } from \"../messages.js\";\nimport { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from \"../model-config.js\";\nimport { listOAuthProviders, login, logout, type SupportedOAuthProvider } from \"../oauth/index.js\";\nimport {\n\tgetLatestCompactionEntry,\n\tloadSessionFromEntries,\n\ttype SessionManager,\n\tSUMMARY_PREFIX,\n\tSUMMARY_SUFFIX,\n} from \"../session-manager.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { getShellConfig, killProcessTree, sanitizeBinaryOutput } from \"../shell.js\";\nimport { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from \"../slash-commands.js\";\nimport { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from \"../theme/theme.js\";\nimport { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from \"../tools/truncate.js\";\nimport { AssistantMessageComponent } from \"./assistant-message.js\";\nimport { BashExecutionComponent } from \"./bash-execution.js\";\nimport { CompactionComponent } from \"./compaction.js\";\nimport { CustomEditor } from \"./custom-editor.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { FooterComponent } from \"./footer.js\";\nimport { ModelSelectorComponent } from \"./model-selector.js\";\nimport { OAuthSelectorComponent } from \"./oauth-selector.js\";\nimport { QueueModeSelectorComponent } from \"./queue-mode-selector.js\";\nimport { SessionSelectorComponent } from \"./session-selector.js\";\nimport { ThemeSelectorComponent } from \"./theme-selector.js\";\nimport { ThinkingSelectorComponent } from \"./thinking-selector.js\";\nimport { ToolExecutionComponent } from \"./tool-execution.js\";\nimport { UserMessageComponent } from \"./user-message.js\";\nimport { UserMessageSelectorComponent } from \"./user-message-selector.js\";\n\n/**\n * TUI renderer for the coding agent\n */\nexport class TuiRenderer {\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate editor: CustomEditor;\n\tprivate editorContainer: Container; // Container to swap between editor and selector\n\tprivate footer: FooterComponent;\n\tprivate agent: Agent;\n\tprivate sessionManager: SessionManager;\n\tprivate settingsManager: SettingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | null = null;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | null = null;\n\tprivate collapseChangelog = false;\n\n\t// Message queueing\n\tprivate queuedMessages: string[] = [];\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | null = null;\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map();\n\n\t// Thinking level selector\n\tprivate thinkingSelector: ThinkingSelectorComponent | null = null;\n\n\t// Queue mode selector\n\tprivate queueModeSelector: QueueModeSelectorComponent | null = null;\n\n\t// Theme selector\n\tprivate themeSelector: ThemeSelectorComponent | null = null;\n\n\t// Model selector\n\tprivate modelSelector: ModelSelectorComponent | null = null;\n\n\t// User message selector (for branching)\n\tprivate userMessageSelector: UserMessageSelectorComponent | null = null;\n\n\t// Session selector (for resume)\n\tprivate sessionSelector: SessionSelectorComponent | null = null;\n\n\t// OAuth selector\n\tprivate oauthSelector: any | null = null;\n\n\t// Track if this is the first user message (to skip spacer)\n\tprivate isFirstUserMessage = true;\n\n\t// Model scope for quick cycling\n\tprivate scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\n\t// File-based slash commands\n\tprivate fileCommands: FileSlashCommand[] = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Track running bash command process for cancellation\n\tprivate bashProcess: ReturnType | null = null;\n\n\t// Track current bash execution component\n\tprivate bashComponent: BashExecutionComponent | null = null;\n\n\tconstructor(\n\t\tagent: Agent,\n\t\tsessionManager: SessionManager,\n\t\tsettingsManager: SettingsManager,\n\t\tversion: string,\n\t\tchangelogMarkdown: string | null = null,\n\t\tcollapseChangelog = false,\n\t\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\t\tfdPath: string | null = null,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.sessionManager = sessionManager;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.version = version;\n\t\tthis.changelogMarkdown = changelogMarkdown;\n\t\tthis.collapseChangelog = collapseChangelog;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.editor = new CustomEditor(getEditorTheme());\n\t\tthis.editorContainer = new Container(); // Container to hold editor or selector\n\t\tthis.editorContainer.addChild(this.editor); // Start with editor\n\t\tthis.footer = new FooterComponent(agent.state);\n\t\tthis.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());\n\n\t\t// Define slash commands\n\t\tconst thinkingCommand: SlashCommand = {\n\t\t\tname: \"thinking\",\n\t\t\tdescription: \"Select reasoning level (opens selector UI)\",\n\t\t};\n\n\t\tconst modelCommand: SlashCommand = {\n\t\t\tname: \"model\",\n\t\t\tdescription: \"Select model (opens selector UI)\",\n\t\t};\n\n\t\tconst exportCommand: SlashCommand = {\n\t\t\tname: \"export\",\n\t\t\tdescription: \"Export session to HTML file\",\n\t\t};\n\n\t\tconst copyCommand: SlashCommand = {\n\t\t\tname: \"copy\",\n\t\t\tdescription: \"Copy last agent message to clipboard\",\n\t\t};\n\n\t\tconst sessionCommand: SlashCommand = {\n\t\t\tname: \"session\",\n\t\t\tdescription: \"Show session info and stats\",\n\t\t};\n\n\t\tconst changelogCommand: SlashCommand = {\n\t\t\tname: \"changelog\",\n\t\t\tdescription: \"Show changelog entries\",\n\t\t};\n\n\t\tconst branchCommand: SlashCommand = {\n\t\t\tname: \"branch\",\n\t\t\tdescription: \"Create a new branch from a previous message\",\n\t\t};\n\n\t\tconst loginCommand: SlashCommand = {\n\t\t\tname: \"login\",\n\t\t\tdescription: \"Login with OAuth provider\",\n\t\t};\n\n\t\tconst logoutCommand: SlashCommand = {\n\t\t\tname: \"logout\",\n\t\t\tdescription: \"Logout from OAuth provider\",\n\t\t};\n\n\t\tconst queueCommand: SlashCommand = {\n\t\t\tname: \"queue\",\n\t\t\tdescription: \"Select message queue mode (opens selector UI)\",\n\t\t};\n\n\t\tconst themeCommand: SlashCommand = {\n\t\t\tname: \"theme\",\n\t\t\tdescription: \"Select color theme (opens selector UI)\",\n\t\t};\n\n\t\tconst clearCommand: SlashCommand = {\n\t\t\tname: \"clear\",\n\t\t\tdescription: \"Clear context and start a fresh session\",\n\t\t};\n\n\t\tconst compactCommand: SlashCommand = {\n\t\t\tname: \"compact\",\n\t\t\tdescription: \"Manually compact the session context\",\n\t\t};\n\n\t\tconst autocompactCommand: SlashCommand = {\n\t\t\tname: \"autocompact\",\n\t\t\tdescription: \"Toggle automatic context compaction\",\n\t\t};\n\n\t\tconst resumeCommand: SlashCommand = {\n\t\t\tname: \"resume\",\n\t\t\tdescription: \"Resume a different session\",\n\t\t};\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = settingsManager.getHideThinkingBlock();\n\n\t\t// Load file-based slash commands\n\t\tthis.fileCommands = loadSlashCommands();\n\n\t\t// Convert file commands to SlashCommand format\n\t\tconst fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: cmd.description,\n\t\t}));\n\n\t\t// Setup autocomplete for file paths and slash commands\n\t\tconst autocompleteProvider = new CombinedAutocompleteProvider(\n\t\t\t[\n\t\t\t\tthinkingCommand,\n\t\t\t\tmodelCommand,\n\t\t\t\tthemeCommand,\n\t\t\t\texportCommand,\n\t\t\t\tcopyCommand,\n\t\t\t\tsessionCommand,\n\t\t\t\tchangelogCommand,\n\t\t\t\tbranchCommand,\n\t\t\t\tloginCommand,\n\t\t\t\tlogoutCommand,\n\t\t\t\tqueueCommand,\n\t\t\t\tclearCommand,\n\t\t\t\tcompactCommand,\n\t\t\t\tautocompactCommand,\n\t\t\t\tresumeCommand,\n\t\t\t\t...fileSlashCommands,\n\t\t\t],\n\t\t\tprocess.cwd(),\n\t\t\tfdPath,\n\t\t);\n\t\tthis.editor.setAutocompleteProvider(autocompleteProvider);\n\t}\n\n\tasync init(): Promise {\n\t\tif (this.isInitialized) return;\n\n\t\t// Add header with logo and instructions\n\t\tconst logo = theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\t\tconst instructions =\n\t\t\ttheme.fg(\"dim\", \"esc\") +\n\t\t\ttheme.fg(\"muted\", \" to interrupt\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c\") +\n\t\t\ttheme.fg(\"muted\", \" to clear\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c twice\") +\n\t\t\ttheme.fg(\"muted\", \" to exit\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+k\") +\n\t\t\ttheme.fg(\"muted\", \" to delete line\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"shift+tab\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+p\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle models\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+o\") +\n\t\t\ttheme.fg(\"muted\", \" to expand tools\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+t\") +\n\t\t\ttheme.fg(\"muted\", \" to toggle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"/\") +\n\t\t\ttheme.fg(\"muted\", \" for commands\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"!\") +\n\t\t\ttheme.fg(\"muted\", \" to run bash\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"drop files\") +\n\t\t\ttheme.fg(\"muted\", \" to attach\");\n\t\tconst header = new Text(logo + \"\\n\" + instructions, 1, 0);\n\n\t\t// Setup UI layout\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(header);\n\t\tthis.ui.addChild(new Spacer(1));\n\n\t\t// Add changelog if provided\n\t\tif (this.changelogMarkdown) {\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t\tif (this.collapseChangelog) {\n\t\t\t\t// Show condensed version with hint to use /changelog\n\t\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\t\tthis.ui.addChild(new Text(condensedText, 1, 0));\n\t\t\t} else {\n\t\t\t\tthis.ui.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t\tthis.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t}\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(this.editorContainer); // Use container that can hold editor or selector\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\t// Set up custom key handlers on the editor\n\t\tthis.editor.onEscape = () => {\n\t\t\t// Intercept Escape key when processing\n\t\t\tif (this.loadingAnimation) {\n\t\t\t\t// Get all queued messages\n\t\t\t\tconst queuedText = this.queuedMessages.join(\"\\n\\n\");\n\n\t\t\t\t// Get current editor text\n\t\t\t\tconst currentText = this.editor.getText();\n\n\t\t\t\t// Combine: queued messages + current editor text\n\t\t\t\tconst combinedText = [queuedText, currentText].filter((t) => t.trim()).join(\"\\n\\n\");\n\n\t\t\t\t// Put back in editor\n\t\t\t\tthis.editor.setText(combinedText);\n\n\t\t\t\t// Clear queued messages\n\t\t\t\tthis.queuedMessages = [];\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Clear agent's queue too\n\t\t\t\tthis.agent.clearMessageQueue();\n\n\t\t\t\t// Abort\n\t\t\t\tthis.agent.abort();\n\t\t\t} else if (this.bashProcess) {\n\t\t\t\t// Kill running bash command\n\t\t\t\tif (this.bashProcess.pid) {\n\t\t\t\t\tkillProcessTree(this.bashProcess.pid);\n\t\t\t\t}\n\t\t\t\tthis.bashProcess = null;\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\t// Cancel bash mode and clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /branch\n\t\t\t\tconst now = Date.now();\n\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\tthis.lastEscapeTime = 0; // Reset to prevent triple-escape\n\t\t\t\t} else {\n\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tthis.editor.onCtrlC = () => {\n\t\t\tthis.handleCtrlC();\n\t\t};\n\n\t\tthis.editor.onShiftTab = () => {\n\t\t\tthis.cycleThinkingLevel();\n\t\t};\n\n\t\tthis.editor.onCtrlP = () => {\n\t\t\tthis.cycleModel();\n\t\t};\n\n\t\tthis.editor.onCtrlO = () => {\n\t\t\tthis.toggleToolOutputExpansion();\n\t\t};\n\n\t\tthis.editor.onCtrlT = () => {\n\t\t\tthis.toggleThinkingBlockVisibility();\n\t\t};\n\n\t\t// Handle editor text changes for bash mode detection\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\n\t\t// Handle editor submission\n\t\tthis.editor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Check for /thinking command\n\t\t\tif (text === \"/thinking\") {\n\t\t\t\t// Show thinking level selector\n\t\t\t\tthis.showThinkingSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /model command\n\t\t\tif (text === \"/model\") {\n\t\t\t\t// Show model selector\n\t\t\t\tthis.showModelSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /export command\n\t\t\tif (text.startsWith(\"/export\")) {\n\t\t\t\tthis.handleExportCommand(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /copy command\n\t\t\tif (text === \"/copy\") {\n\t\t\t\tthis.handleCopyCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /session command\n\t\t\tif (text === \"/session\") {\n\t\t\t\tthis.handleSessionCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /changelog command\n\t\t\tif (text === \"/changelog\") {\n\t\t\t\tthis.handleChangelogCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /branch command\n\t\t\tif (text === \"/branch\") {\n\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /login command\n\t\t\tif (text === \"/login\") {\n\t\t\t\tthis.showOAuthSelector(\"login\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /logout command\n\t\t\tif (text === \"/logout\") {\n\t\t\t\tthis.showOAuthSelector(\"logout\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /queue command\n\t\t\tif (text === \"/queue\") {\n\t\t\t\tthis.showQueueModeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /theme command\n\t\t\tif (text === \"/theme\") {\n\t\t\t\tthis.showThemeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /clear command\n\t\t\tif (text === \"/clear\") {\n\t\t\t\tthis.handleClearCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /compact command\n\t\t\tif (text === \"/compact\" || text.startsWith(\"/compact \")) {\n\t\t\t\tconst customInstructions = text.startsWith(\"/compact \") ? text.slice(9).trim() : undefined;\n\t\t\t\tthis.handleCompactCommand(customInstructions);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /autocompact command\n\t\t\tif (text === \"/autocompact\") {\n\t\t\t\tthis.handleAutocompactCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /debug command\n\t\t\tif (text === \"/debug\") {\n\t\t\t\tthis.handleDebugCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /resume command\n\t\t\tif (text === \"/resume\") {\n\t\t\t\tthis.showSessionSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for bash command (!)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst command = text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\t// Block if bash already running\n\t\t\t\t\tif (this.bashProcess) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\t// Restore text since editor clears on submit\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\t\tthis.editor.addToHistory(text);\n\t\t\t\t\tthis.handleBashCommand(command);\n\t\t\t\t\t// Reset bash mode since editor is now empty\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for file-based slash commands\n\t\t\ttext = expandSlashCommand(text, this.fileCommands);\n\n\t\t\t// Normal message submission - validate model and API key first\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tif (!currentModel) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t\"No model selected.\\n\\n\" +\n\t\t\t\t\t\t\"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\\n\" +\n\t\t\t\t\t\t`or create ${getModelsPath()}\\n\\n` +\n\t\t\t\t\t\t\"Then use /model to select a model.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Validate API key (async)\n\t\t\tconst apiKey = await getApiKeyForModel(currentModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t`No API key found for ${currentModel.provider}.\\n\\n` +\n\t\t\t\t\t\t`Set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t);\n\t\t\t\tthis.editor.setText(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check if agent is currently streaming\n\t\t\tif (this.agent.state.isStreaming) {\n\t\t\t\t// Queue the message instead of submitting\n\t\t\t\tthis.queuedMessages.push(text);\n\n\t\t\t\t// Queue in agent\n\t\t\t\tawait this.agent.queueMessage({\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\n\t\t\t\t// Update pending messages display\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\tthis.editor.addToHistory(text);\n\n\t\t\t\t// Clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// All good, proceed with submission\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\n\t\t\t// Add to history for up/down arrow navigation\n\t\t\tthis.editor.addToHistory(text);\n\t\t};\n\n\t\t// Start the UI\n\t\tthis.ui.start();\n\t\tthis.isInitialized = true;\n\n\t\t// Subscribe to agent events for UI updates and session saving\n\t\tthis.subscribeToAgent();\n\n\t\t// Set up theme file watcher for live reload\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher\n\t\tthis.footer.watchBranch(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.agent.subscribe(async (event) => {\n\t\t\t// Handle UI updates\n\t\t\tawait this.handleEvent(event, this.agent.state);\n\n\t\t\t// Save messages to session\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tthis.sessionManager.saveMessage(event.message);\n\n\t\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\t\tif (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {\n\t\t\t\t\tthis.sessionManager.startSession(this.agent.state);\n\t\t\t\t}\n\n\t\t\t\t// Check for auto-compaction after assistant messages\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\tawait this.checkAutoCompaction();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate async checkAutoCompaction(): Promise {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message from agent state\n\t\tconst messages = this.agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = this.agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tawait this.executeCompaction(undefined, true);\n\t}\n\n\tprivate async handleEvent(event: AgentEvent, state: AgentState): Promise {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\t// Update footer with current stats\n\t\tthis.footer.updateState(state);\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\t// Show loading animation\n\t\t\t\t// Note: Don't disable submit - we handle queuing in onSubmit callback\n\t\t\t\t// Stop old loader before clearing\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t}\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.loadingAnimation = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\"Working... (esc to interrupt)\",\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\t// Check if this is a queued message\n\t\t\t\t\tconst userMsg = event.message;\n\t\t\t\t\tconst textBlocks =\n\t\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\t\tconst messageText = textBlocks.map((c) => c.text).join(\"\");\n\n\t\t\t\t\tconst queuedIndex = this.queuedMessages.indexOf(messageText);\n\t\t\t\t\tif (queuedIndex !== -1) {\n\t\t\t\t\t\t// Remove from queued messages\n\t\t\t\t\t\tthis.queuedMessages.splice(queuedIndex, 1);\n\t\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Show user message immediately and clear editor\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\t// Create assistant component for streaming\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(event.message as AssistantMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\t// Update streaming component\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// Create tool execution components as soon as we see tool calls\n\t\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\t// Only create if we haven't created it yet\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(\"\", 0, 0));\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// Update existing component with latest arguments as they stream\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\t// Skip user messages (already shown in message_start)\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\n\t\t\t\t\t// Update streaming component with final message (includes stopReason)\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// If message was aborted or errored, mark all pending tool components as failed\n\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\" ? \"Operation aborted\" : assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\tfor (const [toolCallId, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Keep the streaming component - it's now the final assistant message\n\t\t\t\t\tthis.streamingComponent = null;\n\n\t\t\t\t\t// Invalidate footer cache to refresh git branch (in case agent executed git commands)\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\t// Component should already exist from message_update, but create if missing\n\t\t\t\tif (!this.pendingTools.has(event.toolCallId)) {\n\t\t\t\t\tconst component = new ToolExecutionComponent(event.toolName, event.args);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Update the existing tool component with the result\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\t// Convert result to the format expected by updateResult\n\t\t\t\t\tconst resultData =\n\t\t\t\t\t\ttypeof event.result === \"string\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: event.result }],\n\t\t\t\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\tcontent: event.result.content,\n\t\t\t\t\t\t\t\t\tdetails: event.result.details,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\tcomponent.updateResult(resultData);\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\t// Stop loading animation\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = null;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = null;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// Note: Don't need to re-enable submit - we never disable it\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate addMessageToChat(message: Message | AppMessage): void {\n\t\t// Handle bash execution messages\n\t\tif (isBashExecutionMessage(message)) {\n\t\t\tconst bashMsg = message as BashExecutionMessage;\n\t\t\tconst component = new BashExecutionComponent(bashMsg.command, this.ui);\n\t\t\tif (bashMsg.output) {\n\t\t\t\tcomponent.appendOutput(bashMsg.output);\n\t\t\t}\n\t\t\tcomponent.setComplete(\n\t\t\t\tbashMsg.exitCode,\n\t\t\t\tbashMsg.cancelled,\n\t\t\t\tbashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\tbashMsg.fullOutputPath,\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(component);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.role === \"user\") {\n\t\t\tconst userMsg = message;\n\t\t\t// Extract text content from content blocks\n\t\t\tconst textBlocks =\n\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\tif (textContent) {\n\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t}\n\t\t} else if (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message;\n\n\t\t\t// Add assistant message component\n\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t}\n\t\t// Note: tool calls and results are now handled via tool_execution_start/end events\n\t}\n\n\trenderInitialMessages(state: AgentState): void {\n\t\t// Render all existing messages (for --continue mode)\n\t\t// Reset first user message flag for initial render\n\t\tthis.isFirstUserMessage = true;\n\n\t\t// Update footer with loaded state\n\t\tthis.footer.updateState(state);\n\n\t\t// Update editor border color based on current thinking level\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\t// Render messages\n\t\tfor (let i = 0; i < state.messages.length; i++) {\n\t\t\tconst message = state.messages[i];\n\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\t// Create tool execution components for any tool calls\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\t// If message was aborted/errored, immediately mark tool as failed\n\t\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\"\n\t\t\t\t\t\t\t\t\t? \"Operation aborted\"\n\t\t\t\t\t\t\t\t\t: assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Store in map so we can update with results later\n\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Update existing tool execution component with results\t\t\t\t;\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\t// Remove from pending map since it's complete\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Clear pending tools after rendering initial messages\n\t\tthis.pendingTools.clear();\n\n\t\t// Populate editor history with user messages from the session (oldest first so newest is at index 0)\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof message.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t\t\t: message.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\t// Skip compaction summary messages\n\t\t\t\tif (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {\n\t\t\t\t\tthis.editor.addToHistory(textContent);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tasync getUserInput(): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\t// Reset state and re-render messages from agent state\n\t\tthis.isFirstUserMessage = true;\n\t\tthis.pendingTools.clear();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\tfor (const message of this.agent.state.messages) {\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.pendingTools.clear();\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleCtrlC(): void {\n\t\t// Handle Ctrl+C double-press logic\n\t\tconst now = Date.now();\n\t\tconst timeSinceLastCtrlC = now - this.lastSigintTime;\n\n\t\tif (timeSinceLastCtrlC < 500) {\n\t\t\t// Second Ctrl+C within 500ms - exit\n\t\t\tthis.stop();\n\t\t\tprocess.exit(0);\n\t\t} else {\n\t\t\t// First Ctrl+C - clear the editor\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.agent.state.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\t// Only cycle if model supports thinking\n\t\tif (!this.agent.state.model?.reasoning) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Current model does not support thinking\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// xhigh is only available for codex-max models\n\t\tconst modelId = this.agent.state.model?.id || \"\";\n\t\tconst supportsXhigh = modelId.includes(\"codex-max\");\n\t\tconst levels: ThinkingLevel[] = supportsXhigh\n\t\t\t? [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]\n\t\t\t: [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\t\tconst currentLevel = this.agent.state.thinkingLevel || \"off\";\n\t\tconst currentIndex = levels.indexOf(currentLevel);\n\t\tconst nextIndex = (currentIndex + 1) % levels.length;\n\t\tconst nextLevel = levels[nextIndex];\n\n\t\t// Apply the new thinking level\n\t\tthis.agent.setThinkingLevel(nextLevel);\n\n\t\t// Save thinking level change to session and settings\n\t\tthis.sessionManager.saveThinkingLevelChange(nextLevel);\n\t\tthis.settingsManager.setDefaultThinkingLevel(nextLevel);\n\n\t\t// Update border color\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Show brief notification\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking level: ${nextLevel}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async cycleModel(): Promise {\n\t\t// Use scoped models if available, otherwise all available models\n\t\tif (this.scopedModels.length > 0) {\n\t\t\t// Use scoped models with thinking levels\n\t\t\tif (this.scopedModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model in scope\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = this.scopedModels.findIndex(\n\t\t\t\t(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % this.scopedModels.length;\n\t\t\tconst nextEntry = this.scopedModels[nextIndex];\n\t\t\tconst nextModel = nextEntry.model;\n\t\t\tconst nextThinking = nextEntry.thinkingLevel;\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Apply thinking level (silently use \"off\" if model doesn't support thinking)\n\t\t\tconst effectiveThinking = nextModel.reasoning ? nextThinking : \"off\";\n\t\t\tthis.agent.setThinkingLevel(effectiveThinking);\n\t\t\tthis.sessionManager.saveThinkingLevelChange(effectiveThinking);\n\t\t\tthis.settingsManager.setDefaultThinkingLevel(effectiveThinking);\n\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tconst thinkingStr = nextModel.reasoning && nextThinking !== \"off\" ? ` (thinking: ${nextThinking})` : \"\";\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),\n\t\t\t);\n\t\t\tthis.ui.requestRender();\n\t\t} else {\n\t\t\t// Fallback to all available models (no thinking level changes)\n\t\t\tconst { models: availableModels, error } = await getAvailableModels();\n\t\t\tif (error) {\n\t\t\t\tthis.showError(`Failed to load models: ${error}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 0) {\n\t\t\t\tthis.showError(\"No models available to cycle\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model available\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = availableModels.findIndex(\n\t\t\t\t(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % availableModels.length;\n\t\t\tconst nextModel = availableModels[nextIndex];\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool execution, compaction, and bash execution components\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Update all assistant message components and rebuild their content\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\t}\n\t\t}\n\n\t\t// Rebuild chat to apply visibility change\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// Show brief notification\n\t\tconst status = this.hideThinkingBlock ? \"hidden\" : \"visible\";\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking blocks: ${status}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\t// Show error message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\t// Show warning message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"warning\", `Warning: ${warningMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\t// Show new version notification in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\ttheme.bold(theme.fg(\"warning\", \"Update Available\")) +\n\t\t\t\t\t\"\\n\" +\n\t\t\t\t\ttheme.fg(\"muted\", `New version ${newVersion} is available. Run: `) +\n\t\t\t\t\ttheme.fg(\"accent\", \"npm install -g @mariozechner/pi-coding-agent\"),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showThinkingSelector(): void {\n\t\t// Create thinking selector with current level\n\t\tthis.thinkingSelector = new ThinkingSelectorComponent(\n\t\t\tthis.agent.state.thinkingLevel,\n\t\t\t(level) => {\n\t\t\t\t// Apply the selected thinking level\n\t\t\t\tthis.agent.setThinkingLevel(level);\n\n\t\t\t\t// Save thinking level change to session and settings\n\t\t\t\tthis.sessionManager.saveThinkingLevelChange(level);\n\t\t\t\tthis.settingsManager.setDefaultThinkingLevel(level);\n\n\t\t\t\t// Update border color\n\t\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Thinking level: ${level}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.thinkingSelector);\n\t\tthis.ui.setFocus(this.thinkingSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThinkingSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.thinkingSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showQueueModeSelector(): void {\n\t\t// Create queue mode selector with current mode\n\t\tthis.queueModeSelector = new QueueModeSelectorComponent(\n\t\t\tthis.agent.getQueueMode(),\n\t\t\t(mode) => {\n\t\t\t\t// Apply the selected queue mode\n\t\t\t\tthis.agent.setQueueMode(mode);\n\n\t\t\t\t// Save queue mode to settings\n\t\t\t\tthis.settingsManager.setQueueMode(mode);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Queue mode: ${mode}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.queueModeSelector);\n\t\tthis.ui.setFocus(this.queueModeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideQueueModeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.queueModeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showThemeSelector(): void {\n\t\t// Get current theme from settings\n\t\tconst currentTheme = this.settingsManager.getTheme() || \"dark\";\n\n\t\t// Create theme selector\n\t\tthis.themeSelector = new ThemeSelectorComponent(\n\t\t\tcurrentTheme,\n\t\t\t(themeName) => {\n\t\t\t\t// Apply the selected theme\n\t\t\t\tconst result = setTheme(themeName);\n\n\t\t\t\t// Save theme to settings\n\t\t\t\tthis.settingsManager.setTheme(themeName);\n\n\t\t\t\t// Invalidate all components to clear cached rendering\n\t\t\t\tthis.ui.invalidate();\n\n\t\t\t\t// Show confirmation or error message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tif (result.success) {\n\t\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Theme: ${themeName}`), 1, 0);\n\t\t\t\t\tthis.chatContainer.addChild(confirmText);\n\t\t\t\t} else {\n\t\t\t\t\tconst errorText = new Text(\n\t\t\t\t\t\ttheme.fg(\"error\", `Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`),\n\t\t\t\t\t\t1,\n\t\t\t\t\t\t0,\n\t\t\t\t\t);\n\t\t\t\t\tthis.chatContainer.addChild(errorText);\n\t\t\t\t}\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t(themeName) => {\n\t\t\t\t// Preview theme on selection change\n\t\t\t\tconst result = setTheme(themeName);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\t// If failed, theme already fell back to dark, just don't re-render\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.themeSelector);\n\t\tthis.ui.setFocus(this.themeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThemeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.themeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showModelSelector(): void {\n\t\t// Create model selector with current model\n\t\tthis.modelSelector = new ModelSelectorComponent(\n\t\t\tthis.ui,\n\t\t\tthis.agent.state.model,\n\t\t\tthis.settingsManager,\n\t\t\t(model) => {\n\t\t\t\t// Apply the selected model\n\t\t\t\tthis.agent.setModel(model);\n\n\t\t\t\t// Save model change to session\n\t\t\t\tthis.sessionManager.saveModelChange(model.provider, model.id);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Model: ${model.id}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.modelSelector);\n\t\tthis.ui.setFocus(this.modelSelector);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideModelSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.modelSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\t// Read from session file directly to see ALL historical user messages\n\t\t// (including those before compaction events)\n\t\tconst entries = this.sessionManager.loadEntries();\n\t\tconst userMessages: Array<{ index: number; text: string }> = [];\n\n\t\tconst getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {\n\t\t\tif (typeof content === \"string\") return content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\treturn content\n\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t.join(\"\");\n\t\t\t}\n\t\t\treturn \"\";\n\t\t};\n\n\t\tfor (let i = 0; i < entries.length; i++) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tif (entry.message.role !== \"user\") continue;\n\n\t\t\tconst textContent = getUserMessageText(entry.message.content);\n\t\t\tif (textContent) {\n\t\t\t\tuserMessages.push({ index: i, text: textContent });\n\t\t\t}\n\t\t}\n\n\t\t// Don't show selector if there are no messages or only one message\n\t\tif (userMessages.length <= 1) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No messages to branch from\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// Create user message selector\n\t\tthis.userMessageSelector = new UserMessageSelectorComponent(\n\t\t\tuserMessages,\n\t\t\t(entryIndex) => {\n\t\t\t\t// Get the selected user message text to put in the editor\n\t\t\t\tconst selectedEntry = entries[entryIndex];\n\t\t\t\tif (selectedEntry.type !== \"message\") return;\n\t\t\t\tif (selectedEntry.message.role !== \"user\") return;\n\n\t\t\t\tconst selectedText = getUserMessageText(selectedEntry.message.content);\n\n\t\t\t\t// Create a branched session by copying entries up to (but not including) the selected entry\n\t\t\t\tconst newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);\n\n\t\t\t\t// Set the new session file as active\n\t\t\t\tthis.sessionManager.setSessionFile(newSessionFile);\n\n\t\t\t\t// Reload the session\n\t\t\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\t\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t\t\t// Clear and re-render the chat\n\t\t\t\tthis.chatContainer.clear();\n\t\t\t\tthis.isFirstUserMessage = true;\n\t\t\t\tthis.renderInitialMessages(this.agent.state);\n\n\t\t\t\t// Show confirmation message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Branched to new session\"), 1, 0));\n\n\t\t\t\t// Put the selected message in the editor","truncated":true,"truncatedBy":"bytes","totalLines":2377,"totalBytes":75229,"outputLines":1603,"outputBytes":51190,"lastLinePartial":false,"firstLineExceedsLimit":false}},"isError":false,"timestamp":1765233669399}}
+{"type":"message","timestamp":"2025-12-08T22:41:09.394Z","message":{"role":"toolResult","toolCallId":"toolu_012yuiPP1VAfh196GXaAmT8D","toolName":"read","content":[{"type":"text","text":"import { Agent, type Attachment, ProviderTransport, type ThinkingLevel } from \"@oh-my-pi/pi-agent-core\";\nimport type { Api, AssistantMessage, KnownProvider, Model } from \"@oh-my-pi/pi-ai\";\nimport { ProcessTerminal, TUI } from \"@oh-my-pi/pi-tui\";\nimport chalk from \"chalk\";\nimport { spawn } from \"child_process\";\nimport { randomBytes } from \"crypto\";\nimport { createWriteStream, existsSync, readFileSync, statSync } from \"fs\";\nimport { homedir, tmpdir } from \"os\";\nimport { extname, join, resolve } from \"path\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, getNewEntries, parseChangelog } from \"./changelog.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"./compaction.js\";\nimport {\n\tAPP_NAME,\n\tCONFIG_DIR_NAME,\n\tENV_AGENT_DIR,\n\tgetAgentDir,\n\tgetModelsPath,\n\tgetReadmePath,\n\tVERSION,\n} from \"./config.js\";\nimport { exportFromFile } from \"./export-html.js\";\nimport { type BashExecutionMessage, messageTransformer } from \"./messages.js\";\nimport { findModel, getApiKeyForModel, getAvailableModels } from \"./model-config.js\";\nimport { loadSessionFromEntries, SessionManager } from \"./session-manager.js\";\nimport { SettingsManager } from \"./settings-manager.js\";\nimport { getShellConfig } from \"./shell.js\";\nimport { expandSlashCommand, loadSlashCommands } from \"./slash-commands.js\";\nimport { initTheme } from \"./theme/theme.js\";\nimport { allTools, codingTools, type ToolName } from \"./tools/index.js\";\nimport { DEFAULT_MAX_BYTES, truncateTail } from \"./tools/truncate.js\";\nimport { ensureTool } from \"./tools-manager.js\";\nimport { SessionSelectorComponent } from \"./tui/session-selector.js\";\nimport { TuiRenderer } from \"./tui/tui-renderer.js\";\n\nconst defaultModelPerProvider: Record = {\n\tanthropic: \"claude-sonnet-4-5\",\n\topenai: \"gpt-5.1-codex\",\n\tgoogle: \"gemini-2.5-pro\",\n\topenrouter: \"openai/gpt-5.1-codex\",\n\txai: \"grok-4-fast-non-reasoning\",\n\tgroq: \"openai/gpt-oss-120b\",\n\tcerebras: \"zai-glm-4.6\",\n\tzai: \"glm-4.6\",\n};\n\ntype Mode = \"text\" | \"json\" | \"rpc\";\n\ninterface Args {\n\tprovider?: string;\n\tmodel?: string;\n\tapiKey?: string;\n\tsystemPrompt?: string;\n\tappendSystemPrompt?: string;\n\tthinking?: ThinkingLevel;\n\tcontinue?: boolean;\n\tresume?: boolean;\n\thelp?: boolean;\n\tmode?: Mode;\n\tnoSession?: boolean;\n\tsession?: string;\n\tmodels?: string[];\n\ttools?: ToolName[];\n\tprint?: boolean;\n\texport?: string;\n\tmessages: string[];\n\tfileArgs: string[];\n}\n\nfunction parseArgs(args: string[]): Args {\n\tconst result: Args = {\n\t\tmessages: [],\n\t\tfileArgs: [],\n\t};\n\n\tfor (let i = 0; i < args.length; i++) {\n\t\tconst arg = args[i];\n\n\t\tif (arg === \"--help\" || arg === \"-h\") {\n\t\t\tresult.help = true;\n\t\t} else if (arg === \"--mode\" && i + 1 < args.length) {\n\t\t\tconst mode = args[++i];\n\t\t\tif (mode === \"text\" || mode === \"json\" || mode === \"rpc\") {\n\t\t\t\tresult.mode = mode;\n\t\t\t}\n\t\t} else if (arg === \"--continue\" || arg === \"-c\") {\n\t\t\tresult.continue = true;\n\t\t} else if (arg === \"--resume\" || arg === \"-r\") {\n\t\t\tresult.resume = true;\n\t\t} else if (arg === \"--provider\" && i + 1 < args.length) {\n\t\t\tresult.provider = args[++i];\n\t\t} else if (arg === \"--model\" && i + 1 < args.length) {\n\t\t\tresult.model = args[++i];\n\t\t} else if (arg === \"--api-key\" && i + 1 < args.length) {\n\t\t\tresult.apiKey = args[++i];\n\t\t} else if (arg === \"--system-prompt\" && i + 1 < args.length) {\n\t\t\tresult.systemPrompt = args[++i];\n\t\t} else if (arg === \"--append-system-prompt\" && i + 1 < args.length) {\n\t\t\tresult.appendSystemPrompt = args[++i];\n\t\t} else if (arg === \"--no-session\") {\n\t\t\tresult.noSession = true;\n\t\t} else if (arg === \"--session\" && i + 1 < args.length) {\n\t\t\tresult.session = args[++i];\n\t\t} else if (arg === \"--models\" && i + 1 < args.length) {\n\t\t\tresult.models = args[++i].split(\",\").map((s) => s.trim());\n\t\t} else if (arg === \"--tools\" && i + 1 < args.length) {\n\t\t\tconst toolNames = args[++i].split(\",\").map((s) => s.trim());\n\t\t\tconst validTools: ToolName[] = [];\n\t\t\tfor (const name of toolNames) {\n\t\t\t\tif (name in allTools) {\n\t\t\t\t\tvalidTools.push(name as ToolName);\n\t\t\t\t} else {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\tchalk.yellow(`Warning: Unknown tool \"${name}\". Valid tools: ${Object.keys(allTools).join(\", \")}`),\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tresult.tools = validTools;\n\t\t} else if (arg === \"--thinking\" && i + 1 < args.length) {\n\t\t\tconst level = args[++i];\n\t\t\tif (\n\t\t\t\tlevel === \"off\" ||\n\t\t\t\tlevel === \"minimal\" ||\n\t\t\t\tlevel === \"low\" ||\n\t\t\t\tlevel === \"medium\" ||\n\t\t\t\tlevel === \"high\" ||\n\t\t\t\tlevel === \"xhigh\"\n\t\t\t) {\n\t\t\t\tresult.thinking = level;\n\t\t\t} else {\n\t\t\t\tconsole.error(\n\t\t\t\t\tchalk.yellow(\n\t\t\t\t\t\t`Warning: Invalid thinking level \"${level}\". Valid values: off, minimal, low, medium, high, xhigh`,\n\t\t\t\t\t),\n\t\t\t\t);\n\t\t\t}\n\t\t} else if (arg === \"--print\" || arg === \"-p\") {\n\t\t\tresult.print = true;\n\t\t} else if (arg === \"--export\" && i + 1 < args.length) {\n\t\t\tresult.export = args[++i];\n\t\t} else if (arg.startsWith(\"@\")) {\n\t\t\tresult.fileArgs.push(arg.slice(1)); // Remove @ prefix\n\t\t} else if (!arg.startsWith(\"-\")) {\n\t\t\tresult.messages.push(arg);\n\t\t}\n\t}\n\n\treturn result;\n}\n\n/**\n * Map of file extensions to MIME types for common image formats\n */\nconst IMAGE_MIME_TYPES: Record = {\n\t\".jpg\": \"image/jpeg\",\n\t\".jpeg\": \"image/jpeg\",\n\t\".png\": \"image/png\",\n\t\".gif\": \"image/gif\",\n\t\".webp\": \"image/webp\",\n};\n\n/**\n * Check if a file is an image based on its extension\n */\nfunction isImageFile(filePath: string): string | null {\n\tconst ext = extname(filePath).toLowerCase();\n\treturn IMAGE_MIME_TYPES[ext] || null;\n}\n\n/**\n * Expand ~ to home directory\n */\nfunction expandPath(filePath: string): string {\n\tif (filePath === \"~\") {\n\t\treturn homedir();\n\t}\n\tif (filePath.startsWith(\"~/\")) {\n\t\treturn homedir() + filePath.slice(1);\n\t}\n\treturn filePath;\n}\n\n/**\n * Process @file arguments into text content and image attachments\n */\nfunction processFileArguments(fileArgs: string[]): { textContent: string; imageAttachments: Attachment[] } {\n\tlet textContent = \"\";\n\tconst imageAttachments: Attachment[] = [];\n\n\tfor (const fileArg of fileArgs) {\n\t\t// Expand and resolve path\n\t\tconst expandedPath = expandPath(fileArg);\n\t\tconst absolutePath = resolve(expandedPath);\n\n\t\t// Check if file exists\n\t\tif (!existsSync(absolutePath)) {\n\t\t\tconsole.error(chalk.red(`Error: File not found: ${absolutePath}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\t// Check if file is empty\n\t\tconst stats = statSync(absolutePath);\n\t\tif (stats.size === 0) {\n\t\t\t// Skip empty files\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst mimeType = isImageFile(absolutePath);\n\n\t\tif (mimeType) {\n\t\t\t// Handle image file\n\t\t\tconst content = readFileSync(absolutePath);\n\t\t\tconst base64Content = content.toString(\"base64\");\n\n\t\t\tconst attachment: Attachment = {\n\t\t\t\tid: `file-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,\n\t\t\t\ttype: \"image\",\n\t\t\t\tfileName: absolutePath.split(\"/\").pop() || absolutePath,\n\t\t\t\tmimeType,\n\t\t\t\tsize: stats.size,\n\t\t\t\tcontent: base64Content,\n\t\t\t};\n\n\t\t\timageAttachments.push(attachment);\n\n\t\t\t// Add text reference to image\n\t\t\ttextContent += ` \\n`;\n\t\t} else {\n\t\t\t// Handle text file\n\t\t\ttry {\n\t\t\t\tconst content = readFileSync(absolutePath, \"utf-8\");\n\t\t\t\ttextContent += `\\n${content}\\n \\n`;\n\t\t\t} catch (error: any) {\n\t\t\t\tconsole.error(chalk.red(`Error: Could not read file ${absolutePath}: ${error.message}`));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t}\n\t}\n\n\treturn { textContent, imageAttachments };\n}\n\nfunction printHelp() {\n\tconsole.log(`${chalk.bold(APP_NAME)} - AI coding assistant with read, bash, edit, write tools\n\n${chalk.bold(\"Usage:\")}\n ${APP_NAME} [options] [@files...] [messages...]\n\n${chalk.bold(\"Options:\")}\n --provider Provider name (default: google)\n --model Model ID (default: gemini-2.5-flash)\n --api-key API key (defaults to env vars)\n --system-prompt System prompt (default: coding assistant prompt)\n --append-system-prompt Append text or file contents to the system prompt\n --mode Output mode: text (default), json, or rpc\n --print, -p Non-interactive mode: process prompt and exit\n --continue, -c Continue previous session\n --resume, -r Select a session to resume\n --session Use specific session file\n --no-session Don't save session (ephemeral)\n --models Comma-separated model patterns for quick cycling with Ctrl+P\n --tools Comma-separated list of tools to enable (default: read,bash,edit,write)\n Available: read, bash, edit, write, grep, find, ls\n --thinking Set thinking level: off, minimal, low, medium, high, xhigh\n --export Export session file to HTML and exit\n --help, -h Show this help\n\n${chalk.bold(\"Examples:\")}\n # Interactive mode\n ${APP_NAME}\n\n # Interactive mode with initial prompt\n ${APP_NAME} \"List all .ts files in src/\"\n\n # Include files in initial message\n ${APP_NAME} @prompt.md @image.png \"What color is the sky?\"\n\n # Non-interactive mode (process and exit)\n ${APP_NAME} -p \"List all .ts files in src/\"\n\n # Multiple messages (interactive)\n ${APP_NAME} \"Read package.json\" \"What dependencies do we have?\"\n\n # Continue previous session\n ${APP_NAME} --continue \"What did we discuss?\"\n\n # Use different model\n ${APP_NAME} --provider openai --model gpt-4o-mini \"Help me refactor this code\"\n\n # Limit model cycling to specific models\n ${APP_NAME} --models claude-sonnet,claude-haiku,gpt-4o\n\n # Cycle models with fixed thinking levels\n ${APP_NAME} --models sonnet:high,haiku:low\n\n # Start with a specific thinking level\n ${APP_NAME} --thinking high \"Solve this complex problem\"\n\n # Read-only mode (no file modifications possible)\n ${APP_NAME} --tools read,grep,find,ls -p \"Review the code in src/\"\n\n # Export a session file to HTML\n ${APP_NAME} --export ~/${CONFIG_DIR_NAME}/agent/sessions/--path--/session.jsonl\n ${APP_NAME} --export session.jsonl output.html\n\n${chalk.bold(\"Environment Variables:\")}\n ANTHROPIC_API_KEY - Anthropic Claude API key\n ANTHROPIC_OAUTH_TOKEN - Anthropic OAuth token (alternative to API key)\n OPENAI_API_KEY - OpenAI GPT API key\n GEMINI_API_KEY - Google Gemini API key\n GROQ_API_KEY - Groq API key\n CEREBRAS_API_KEY - Cerebras API key\n XAI_API_KEY - xAI Grok API key\n OPENROUTER_API_KEY - OpenRouter API key\n ZAI_API_KEY - ZAI API key\n ${ENV_AGENT_DIR.padEnd(23)} - Session storage directory (default: ~/${CONFIG_DIR_NAME}/agent)\n\n${chalk.bold(\"Available Tools (default: read, bash, edit, write):\")}\n read - Read file contents\n bash - Execute bash commands\n edit - Edit files with find/replace\n write - Write files (creates/overwrites)\n grep - Search file contents (read-only, off by default)\n find - Find files by glob pattern (read-only, off by default)\n ls - List directory contents (read-only, off by default)\n`);\n}\n\n// Tool descriptions for system prompt\nconst toolDescriptions: Record = {\n\tread: \"Read file contents\",\n\tbash: \"Execute bash commands (ls, grep, find, etc.)\",\n\tedit: \"Make surgical edits to files (find exact text and replace)\",\n\twrite: \"Create or overwrite files\",\n\tgrep: \"Search file contents for patterns (respects .gitignore)\",\n\tfind: \"Find files by glob pattern (respects .gitignore)\",\n\tls: \"List directory contents\",\n};\n\nfunction resolvePromptInput(input: string | undefined, description: string): string | undefined {\n\tif (!input) {\n\t\treturn undefined;\n\t}\n\n\tif (existsSync(input)) {\n\t\ttry {\n\t\t\treturn readFileSync(input, \"utf-8\");\n\t\t} catch (error) {\n\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));\n\t\t\treturn input;\n\t\t}\n\t}\n\n\treturn input;\n}\n\nfunction buildSystemPrompt(customPrompt?: string, selectedTools?: ToolName[], appendSystemPrompt?: string): string {\n\tconst resolvedCustomPrompt = resolvePromptInput(customPrompt, \"system prompt\");\n\tconst resolvedAppendPrompt = resolvePromptInput(appendSystemPrompt, \"append system prompt\");\n\n\tconst now = new Date();\n\tconst dateTime = now.toLocaleString(\"en-US\", {\n\t\tweekday: \"long\",\n\t\tyear: \"numeric\",\n\t\tmonth: \"long\",\n\t\tday: \"numeric\",\n\t\thour: \"2-digit\",\n\t\tminute: \"2-digit\",\n\t\tsecond: \"2-digit\",\n\t\ttimeZoneName: \"short\",\n\t});\n\n\tconst appendSection = resolvedAppendPrompt ? `\\n\\n${resolvedAppendPrompt}` : \"\";\n\n\tif (resolvedCustomPrompt) {\n\t\tlet prompt = resolvedCustomPrompt;\n\n\t\tif (appendSection) {\n\t\t\tprompt += appendSection;\n\t\t}\n\n\t\t// Append project context files\n\t\tconst contextFiles = loadProjectContextFiles();\n\t\tif (contextFiles.length > 0) {\n\t\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\t\tprompt += \"The following project context files have been loaded:\\n\\n\";\n\t\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t\t}\n\t\t}\n\n\t\t// Add date/time and working directory last\n\t\tprompt += `\\nCurrent date and time: ${dateTime}`;\n\t\tprompt += `\\nCurrent working directory: ${process.cwd()}`;\n\n\t\treturn prompt;\n\t}\n\n\t// Get absolute path to README.md\n\tconst readmePath = getReadmePath();\n\n\t// Build tools list based on selected tools\n\tconst tools = selectedTools || ([\"read\", \"bash\", \"edit\", \"write\"] as ToolName[]);\n\tconst toolsList = tools.map((t) => `- ${t}: ${toolDescriptions[t]}`).join(\"\\n\");\n\n\t// Build guidelines based on which tools are actually available\n\tconst guidelinesList: string[] = [];\n\n\tconst hasBash = tools.includes(\"bash\");\n\tconst hasEdit = tools.includes(\"edit\");\n\tconst hasWrite = tools.includes(\"write\");\n\tconst hasGrep = tools.includes(\"grep\");\n\tconst hasFind = tools.includes(\"find\");\n\tconst hasLs = tools.includes(\"ls\");\n\tconst hasRead = tools.includes(\"read\");\n\n\t// Read-only mode notice (no bash, edit, or write)\n\tif (!hasBash && !hasEdit && !hasWrite) {\n\t\tguidelinesList.push(\"You are in READ-ONLY mode - you cannot modify files or execute arbitrary commands\");\n\t}\n\n\t// Bash without edit/write = read-only bash mode\n\tif (hasBash && !hasEdit && !hasWrite) {\n\t\tguidelinesList.push(\n\t\t\t\"Use bash ONLY for read-only operations (git log, gh issue view, curl, etc.) - do NOT modify any files\",\n\t\t);\n\t}\n\n\t// File exploration guidelines\n\tif (hasBash && !hasGrep && !hasFind && !hasLs) {\n\t\tguidelinesList.push(\"Use bash for file operations like ls, grep, find\");\n\t} else if (hasBash && (hasGrep || hasFind || hasLs)) {\n\t\tguidelinesList.push(\"Prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)\");\n\t}\n\n\t// Read before edit guideline\n\tif (hasRead && hasEdit) {\n\t\tguidelinesList.push(\"Use read to examine files before editing\");\n\t}\n\n\t// Edit guideline\n\tif (hasEdit) {\n\t\tguidelinesList.push(\"Use edit for precise changes (old text must match exactly)\");\n\t}\n\n\t// Write guideline\n\tif (hasWrite) {\n\t\tguidelinesList.push(\"Use write only for new files or complete rewrites\");\n\t}\n\n\t// Output guideline (only when actually writing/executing)\n\tif (hasEdit || hasWrite) {\n\t\tguidelinesList.push(\n\t\t\t\"When summarizing your actions, output plain text directly - do NOT use cat or bash to display what you did\",\n\t\t);\n\t}\n\n\t// Always include these\n\tguidelinesList.push(\"Be concise in your responses\");\n\tguidelinesList.push(\"Show file paths clearly when working with files\");\n\n\tconst guidelines = guidelinesList.map((g) => `- ${g}`).join(\"\\n\");\n\n\tlet prompt = `You are an expert coding assistant. You help users with coding tasks by reading files, executing commands, editing code, and writing new files.\n\nAvailable tools:\n${toolsList}\n\nGuidelines:\n${guidelines}\n\nDocumentation:\n- Your own documentation (including custom model setup and theme creation) is at: ${readmePath}\n- Read it when users ask about features, configuration, or setup, and especially if the user asks you to add a custom model or provider, or create a custom theme.`;\n\n\tif (appendSection) {\n\t\tprompt += appendSection;\n\t}\n\n\t// Append project context files\n\tconst contextFiles = loadProjectContextFiles();\n\tif (contextFiles.length > 0) {\n\t\tprompt += \"\\n\\n# Project Context\\n\\n\";\n\t\tprompt += \"The following project context files have been loaded:\\n\\n\";\n\t\tfor (const { path: filePath, content } of contextFiles) {\n\t\t\tprompt += `## ${filePath}\\n\\n${content}\\n\\n`;\n\t\t}\n\t}\n\n\t// Add date/time and working directory last\n\tprompt += `\\nCurrent date and time: ${dateTime}`;\n\tprompt += `\\nCurrent working directory: ${process.cwd()}`;\n\n\treturn prompt;\n}\n\n/**\n * Look for AGENTS.md or CLAUDE.md in a directory (prefers AGENTS.md)\n */\nfunction loadContextFileFromDir(dir: string): { path: string; content: string } | null {\n\tconst candidates = [\"AGENTS.md\", \"CLAUDE.md\"];\n\tfor (const filename of candidates) {\n\t\tconst filePath = join(dir, filename);\n\t\tif (existsSync(filePath)) {\n\t\t\ttry {\n\t\t\t\treturn {\n\t\t\t\t\tpath: filePath,\n\t\t\t\t\tcontent: readFileSync(filePath, \"utf-8\"),\n\t\t\t\t};\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));\n\t\t\t}\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Load all project context files in order:\n * 1. Global: ~/{CONFIG_DIR_NAME}/agent/AGENTS.md or CLAUDE.md\n * 2. Parent directories (top-most first) down to cwd\n * Each returns {path, content} for separate messages\n */\nfunction loadProjectContextFiles(): Array<{ path: string; content: string }> {\n\tconst contextFiles: Array<{ path: string; content: string }> = [];\n\n\t// 1. Load global context from ~/{CONFIG_DIR_NAME}/agent/\n\tconst globalContextDir = getAgentDir();\n\tconst globalContext = loadContextFileFromDir(globalContextDir);\n\tif (globalContext) {\n\t\tcontextFiles.push(globalContext);\n\t}\n\n\t// 2. Walk up from cwd to root, collecting all context files\n\tconst cwd = process.cwd();\n\tconst ancestorContextFiles: Array<{ path: string; content: string }> = [];\n\n\tlet currentDir = cwd;\n\tconst root = resolve(\"/\");\n\n\twhile (true) {\n\t\tconst contextFile = loadContextFileFromDir(currentDir);\n\t\tif (contextFile) {\n\t\t\t// Add to beginning so we get top-most parent first\n\t\t\tancestorContextFiles.unshift(contextFile);\n\t\t}\n\n\t\t// Stop if we've reached root\n\t\tif (currentDir === root) break;\n\n\t\t// Move up one directory\n\t\tconst parentDir = resolve(currentDir, \"..\");\n\t\tif (parentDir === currentDir) break; // Safety check\n\t\tcurrentDir = parentDir;\n\t}\n\n\t// Add ancestor files in order (top-most → cwd)\n\tcontextFiles.push(...ancestorContextFiles);\n\n\treturn contextFiles;\n}\n\nasync function checkForNewVersion(currentVersion: string): Promise {\n\ttry {\n\t\tconst response = await fetch(\"https://registry.npmjs.org/@oh-my-pi/pi-coding-agent/latest\");\n\t\tif (!response.ok) return null;\n\n\t\tconst data = (await response.json()) as { version?: string };\n\t\tconst latestVersion = data.version;\n\n\t\tif (latestVersion && latestVersion !== currentVersion) {\n\t\t\treturn latestVersion;\n\t\t}\n\n\t\treturn null;\n\t} catch (error) {\n\t\t// Silently fail - don't disrupt the user experience\n\t\treturn null;\n\t}\n}\n\n/**\n * Resolve model patterns to actual Model objects with optional thinking levels\n * Format: \"pattern:level\" where :level is optional\n * For each pattern, finds all matching models and picks the best version:\n * 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)\n * 2. If no alias, pick the latest dated version\n */\nasync function resolveModelScope(\n\tpatterns: string[],\n): Promise; thinkingLevel: ThinkingLevel }>> {\n\tconst { models: availableModels, error } = await getAvailableModels();\n\n\tif (error) {\n\t\tconsole.warn(chalk.yellow(`Warning: Error loading models: ${error}`));\n\t\treturn [];\n\t}\n\n\tconst scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\tfor (const pattern of patterns) {\n\t\t// Parse pattern:level format\n\t\tconst parts = pattern.split(\":\");\n\t\tconst modelPattern = parts[0];\n\t\tlet thinkingLevel: ThinkingLevel = \"off\";\n\n\t\tif (parts.length > 1) {\n\t\t\tconst level = parts[1];\n\t\t\tif (\n\t\t\t\tlevel === \"off\" ||\n\t\t\t\tlevel === \"minimal\" ||\n\t\t\t\tlevel === \"low\" ||\n\t\t\t\tlevel === \"medium\" ||\n\t\t\t\tlevel === \"high\" ||\n\t\t\t\tlevel === \"xhigh\"\n\t\t\t) {\n\t\t\t\tthinkingLevel = level;\n\t\t\t} else {\n\t\t\t\tconsole.warn(\n\t\t\t\t\tchalk.yellow(`Warning: Invalid thinking level \"${level}\" in pattern \"${pattern}\". Using \"off\" instead.`),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Check for provider/modelId format (provider is everything before the first /)\n\t\tconst slashIndex = modelPattern.indexOf(\"/\");\n\t\tif (slashIndex !== -1) {\n\t\t\tconst provider = modelPattern.substring(0, slashIndex);\n\t\t\tconst modelId = modelPattern.substring(slashIndex + 1);\n\t\t\tconst providerMatch = availableModels.find(\n\t\t\t\t(m) => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),\n\t\t\t);\n\t\t\tif (providerMatch) {\n\t\t\t\tif (\n\t\t\t\t\t!scopedModels.find(\n\t\t\t\t\t\t(sm) => sm.model.id === providerMatch.id && sm.model.provider === providerMatch.provider,\n\t\t\t\t\t)\n\t\t\t\t) {\n\t\t\t\t\tscopedModels.push({ model: providerMatch, thinkingLevel });\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\t// No exact provider/model match - fall through to other matching\n\t\t}\n\n\t\t// Check for exact ID match (case-insensitive)\n\t\tconst exactMatch = availableModels.find((m) => m.id.toLowerCase() === modelPattern.toLowerCase());\n\t\tif (exactMatch) {\n\t\t\t// Exact match found - use it directly\n\t\t\tif (!scopedModels.find((sm) => sm.model.id === exactMatch.id && sm.model.provider === exactMatch.provider)) {\n\t\t\t\tscopedModels.push({ model: exactMatch, thinkingLevel });\n\t\t\t}\n\t\t\tcontinue;\n\t\t}\n\n\t\t// No exact match - fall back to partial matching\n\t\tconst matches = availableModels.filter(\n\t\t\t(m) =>\n\t\t\t\tm.id.toLowerCase().includes(modelPattern.toLowerCase()) ||\n\t\t\t\tm.name?.toLowerCase().includes(modelPattern.toLowerCase()),\n\t\t);\n\n\t\tif (matches.length === 0) {\n\t\t\tconsole.warn(chalk.yellow(`Warning: No models match pattern \"${modelPattern}\"`));\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Helper to check if a model ID looks like an alias (no date suffix)\n\t\t// Dates are typically in format: -20241022 or -20250929\n\t\tconst isAlias = (id: string): boolean => {\n\t\t\t// Check if ID ends with -latest\n\t\t\tif (id.endsWith(\"-latest\")) return true;\n\n\t\t\t// Check if ID ends with a date pattern (-YYYYMMDD)\n\t\t\tconst datePattern = /-\\d{8}$/;\n\t\t\treturn !datePattern.test(id);\n\t\t};\n\n\t\t// Separate into aliases and dated versions\n\t\tconst aliases = matches.filter((m) => isAlias(m.id));\n\t\tconst datedVersions = matches.filter((m) => !isAlias(m.id));\n\n\t\tlet bestMatch: Model;\n\n\t\tif (aliases.length > 0) {\n\t\t\t// Prefer alias - if multiple aliases, pick the one that sorts highest\n\t\t\taliases.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = aliases[0];\n\t\t} else {\n\t\t\t// No alias found, pick latest dated version\n\t\t\tdatedVersions.sort((a, b) => b.id.localeCompare(a.id));\n\t\t\tbestMatch = datedVersions[0];\n\t\t}\n\n\t\t// Avoid duplicates\n\t\tif (!scopedModels.find((sm) => sm.model.id === bestMatch.id && sm.model.provider === bestMatch.provider)) {\n\t\t\tscopedModels.push({ model: bestMatch, thinkingLevel });\n\t\t}\n\t}\n\n\treturn scopedModels;\n}\n\nasync function selectSession(sessionManager: SessionManager): Promise {\n\treturn new Promise((resolve) => {\n\t\tconst ui = new TUI(new ProcessTerminal());\n\t\tlet resolved = false;\n\n\t\tconst selector = new SessionSelectorComponent(\n\t\t\tsessionManager,\n\t\t\t(path: string) => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tui.stop();\n\t\t\t\t\tresolve(path);\n\t\t\t\t}\n\t\t\t},\n\t\t\t() => {\n\t\t\t\tif (!resolved) {\n\t\t\t\t\tresolved = true;\n\t\t\t\t\tui.stop();\n\t\t\t\t\tresolve(null);\n\t\t\t\t}\n\t\t\t},\n\t\t);\n\n\t\tui.addChild(selector);\n\t\tui.setFocus(selector.getSessionList());\n\t\tui.start();\n\t});\n}\n\nasync function runInteractiveMode(\n\tagent: Agent,\n\tsessionManager: SessionManager,\n\tsettingsManager: SettingsManager,\n\tversion: string,\n\tchangelogMarkdown: string | null = null,\n\tcollapseChangelog = false,\n\tmodelFallbackMessage: string | null = null,\n\tversionCheckPromise: Promise,\n\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\tinitialMessages: string[] = [],\n\tinitialMessage?: string,\n\tinitialAttachments?: Attachment[],\n\tfdPath: string | null = null,\n): Promise {\n\tconst renderer = new TuiRenderer(\n\t\tagent,\n\t\tsessionManager,\n\t\tsettingsManager,\n\t\tversion,\n\t\tchangelogMarkdown,\n\t\tcollapseChangelog,\n\t\tscopedModels,\n\t\tfdPath,\n\t);\n\n\t// Initialize TUI (subscribes to agent events internally)\n\tawait renderer.init();\n\n\t// Handle version check result when it completes (don't block)\n\tversionCheckPromise.then((newVersion) => {\n\t\tif (newVersion) {\n\t\t\trenderer.showNewVersionNotification(newVersion);\n\t\t}\n\t});\n\n\t// Render any existing messages (from --continue mode)\n\trenderer.renderInitialMessages(agent.state);\n\n\t// Show model fallback warning at the end of the chat if applicable\n\tif (modelFallbackMessage) {\n\t\trenderer.showWarning(modelFallbackMessage);\n\t}\n\n\t// Load file-based slash commands for expansion\n\tconst fileCommands = loadSlashCommands();\n\n\t// Process initial message with attachments if provided (from @file args)\n\tif (initialMessage) {\n\t\ttry {\n\t\t\tawait agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments);\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Process remaining initial messages if provided (from CLI args)\n\tfor (const message of initialMessages) {\n\t\ttry {\n\t\t\tawait agent.prompt(expandSlashCommand(message, fileCommands));\n\t\t} catch (error: unknown) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n\n\t// Interactive loop\n\twhile (true) {\n\t\tconst userInput = await renderer.getUserInput();\n\n\t\t// Process the message - agent.prompt will add user message and trigger state updates\n\t\ttry {\n\t\t\tawait agent.prompt(userInput);\n\t\t} catch (error: unknown) {\n\t\t\t// Display error in the TUI by adding an error message to the chat\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error occurred\";\n\t\t\trenderer.showError(errorMessage);\n\t\t}\n\t}\n}\n\nasync function runSingleShotMode(\n\tagent: Agent,\n\t_sessionManager: SessionManager,\n\tmessages: string[],\n\tmode: \"text\" | \"json\",\n\tinitialMessage?: string,\n\tinitialAttachments?: Attachment[],\n): Promise {\n\t// Load file-based slash commands for expansion\n\tconst fileCommands = loadSlashCommands();\n\n\tif (mode === \"json\") {\n\t\t// Subscribe to all events and output as JSON\n\t\tagent.subscribe((event) => {\n\t\t\t// Output event as JSON (same format as session manager)\n\t\t\tconsole.log(JSON.stringify(event));\n\t\t});\n\t}\n\n\t// Send initial message with attachments if provided\n\tif (initialMessage) {\n\t\tawait agent.prompt(expandSlashCommand(initialMessage, fileCommands), initialAttachments);\n\t}\n\n\t// Send remaining messages\n\tfor (const message of messages) {\n\t\tawait agent.prompt(expandSlashCommand(message, fileCommands));\n\t}\n\n\t// In text mode, only output the final assistant message\n\tif (mode === \"text\") {\n\t\tconst lastMessage = agent.state.messages[agent.state.messages.length - 1];\n\t\tif (lastMessage.role === \"assistant\") {\n\t\t\tconst assistantMsg = lastMessage as AssistantMessage;\n\n\t\t\t// Check for error/aborted and output error message\n\t\t\tif (assistantMsg.stopReason === \"error\" || assistantMsg.stopReason === \"aborted\") {\n\t\t\t\tconsole.error(assistantMsg.errorMessage || `Request ${assistantMsg.stopReason}`);\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\n\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\tif (content.type === \"text\") {\n\t\t\t\t\tconsole.log(content.text);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Execute a bash command for RPC mode.\n * Similar to tui-renderer's executeBashCommand but without streaming callbacks.\n */\nasync function executeRpcBashCommand(command: string): Promise<{\n\toutput: string;\n\texitCode: number | null;\n\ttruncationResult?: ReturnType;\n\tfullOutputPath?: string;\n}> {\n\treturn new Promise((resolve, reject) => {\n\t\tconst { shell, args } = getShellConfig();\n\t\tconst child = spawn(shell, [...args, command], {\n\t\t\tdetached: true,\n\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t});\n\n\t\tconst chunks: Buffer[] = [];\n\t\tlet chunksBytes = 0;\n\t\tconst maxChunksBytes = DEFAULT_MAX_BYTES * 2;\n\n\t\tlet tempFilePath: string | undefined;\n\t\tlet tempFileStream: ReturnType | undefined;\n\t\tlet totalBytes = 0;\n\n\t\tconst handleData = (data: Buffer) => {\n\t\t\ttotalBytes += data.length;\n\n\t\t\t// Start writing to temp file if exceeds threshold\n\t\t\tif (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {\n\t\t\t\tconst id = randomBytes(8).toString(\"hex\");\n\t\t\t\ttempFilePath = join(tmpdir(), `pi-bash-${id}.log`);\n\t\t\t\ttempFileStream = createWriteStream(tempFilePath);\n\t\t\t\tfor (const chunk of chunks) {\n\t\t\t\t\ttempFileStream.write(chunk);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.write(data);\n\t\t\t}\n\n\t\t\t// Keep rolling buffer\n\t\t\tchunks.push(data);\n\t\t\tchunksBytes += data.length;\n\t\t\twhile (chunksBytes > maxChunksBytes && chunks.length > 1) {\n\t\t\t\tconst removed = chunks.shift()!;\n\t\t\t\tchunksBytes -= removed.length;\n\t\t\t}\n\t\t};\n\n\t\tchild.stdout?.on(\"data\", handleData);\n\t\tchild.stderr?.on(\"data\", handleData);\n\n\t\tchild.on(\"close\", (code) => {\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.end();\n\t\t\t}\n\n\t\t\t// Combine buffered chunks\n\t\t\tconst fullBuffer = Buffer.concat(chunks);\n\t\t\tconst fullOutput = stripAnsi(fullBuffer.toString(\"utf-8\")).replace(/\\r/g, \"\");\n\t\t\tconst truncationResult = truncateTail(fullOutput);\n\n\t\t\tresolve({\n\t\t\t\toutput: fullOutput,\n\t\t\t\texitCode: code,\n\t\t\t\ttruncationResult: truncationResult.truncated ? truncationResult : undefined,\n\t\t\t\tfullOutputPath: tempFilePath,\n\t\t\t});\n\t\t});\n\n\t\tchild.on(\"error\", (err) => {\n\t\t\tif (tempFileStream) {\n\t\t\t\ttempFileStream.end();\n\t\t\t}\n\t\t\treject(err);\n\t\t});\n\t});\n}\n\nasync function runRpcMode(\n\tagent: Agent,\n\tsessionManager: SessionManager,\n\tsettingsManager: SettingsManager,\n): Promise {\n\t// Track if auto-compaction is in progress\n\tlet autoCompactionInProgress = false;\n\n\t// Auto-compaction helper\n\tconst checkAutoCompaction = async () => {\n\t\tif (autoCompactionInProgress) return;\n\n\t\tconst settings = settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message\n\t\tconst messages = agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tautoCompactionInProgress = true;\n\t\ttry {\n\t\t\tconst apiKey = await getApiKeyForModel(agent.state.model);\n\t\t\tif (!apiKey) {\n\t\t\t\tthrow new Error(`No API key for ${agent.state.model.provider}`);\n\t\t\t}\n\n\t\t\tconst entries = sessionManager.loadEntries();\n\t\t\tconst compactionEntry = await compact(entries, agent.state.model, settings, apiKey);\n\n\t\t\tsessionManager.saveCompaction(compactionEntry);\n\t\t\tconst loaded = loadSessionFromEntries(sessionManager.loadEntries());\n\t\t\tagent.replaceMessages(loaded.messages);\n\n\t\t\t// Emit auto-compaction event\n\t\t\tconsole.log(JSON.stringify({ ...compactionEntry, auto: true }));\n\t\t} catch (error: unknown) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Auto-compaction failed: ${message}` }));\n\t\t} finally {\n\t\t\tautoCompactionInProgress = false;\n\t\t}\n\t};\n\n\t// Subscribe to all events and output as JSON (same pattern as tui-renderer)\n\tagent.subscribe(async (event) => {\n\t\tconsole.log(JSON.stringify(event));\n\n\t\t// Save messages to session\n\t\tif (event.type === \"message_end\") {\n\t\t\tsessionManager.saveMessage(event.message);\n\n\t\t\t// Yield to microtask queue to allow agent state to update\n\t\t\t// (tui-renderer does this implicitly via await handleEvent)\n\t\t\tawait Promise.resolve();\n\n\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\tif (sessionManager.shouldInitializeSession(agent.state.messages)) {\n\t\t\t\tsessionManager.startSession(agent.state);\n\t\t\t}\n\n\t\t\t// Check for auto-compaction after assistant messages\n\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\tawait checkAutoCompaction();\n\t\t\t}\n\t\t}\n\t});\n\n\t// Listen for JSON input on stdin\n\tconst readline = await import(\"readline\");\n\tconst rl = readline.createInterface({\n\t\tinput: process.stdin,\n\t\toutput: process.stdout,\n\t\tterminal: false,\n\t});\n\n\trl.on(\"line\", async (line: string) => {\n\t\ttry {\n\t\t\tconst input = JSON.parse(line);\n\n\t\t\t// Handle different RPC commands\n\t\t\tif (input.type === \"prompt\" && input.message) {\n\t\t\t\tawait agent.prompt(input.message, input.attachments);\n\t\t\t} else if (input.type === \"abort\") {\n\t\t\t\tagent.abort();\n\t\t\t} else if (input.type === \"compact\") {\n\t\t\t\t// Handle compaction request\n\t\t\t\ttry {\n\t\t\t\t\tconst apiKey = await getApiKeyForModel(agent.state.model);\n\t\t\t\t\tif (!apiKey) {\n\t\t\t\t\t\tthrow new Error(`No API key for ${agent.state.model.provider}`);\n\t\t\t\t\t}\n\n\t\t\t\t\tconst entries = sessionManager.loadEntries();\n\t\t\t\t\tconst settings = settingsManager.getCompactionSettings();\n\t\t\t\t\tconst compactionEntry = await compact(\n\t\t\t\t\t\tentries,\n\t\t\t\t\t\tagent.state.model,\n\t\t\t\t\t\tsettings,\n\t\t\t\t\t\tapiKey,\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\tinput.customInstructions,\n\t\t\t\t\t);\n\n\t\t\t\t\t// Save and reload\n\t\t\t\t\tsessionManager.saveCompaction(compactionEntry);\n\t\t\t\t\tconst loaded = loadSessionFromEntries(sessionManager.loadEntries());\n\t\t\t\t\tagent.replaceMessages(loaded.messages);\n\n\t\t\t\t\t// Emit compaction event (compactionEntry already has type: \"compaction\")\n\t\t\t\t\tconsole.log(JSON.stringify(compactionEntry));\n\t\t\t\t} catch (error: any) {\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Compaction failed: ${error.message}` }));\n\t\t\t\t}\n\t\t\t} else if (input.type === \"bash\" && input.command) {\n\t\t\t\t// Execute bash command and add to context\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await executeRpcBashCommand(input.command);\n\n\t\t\t\t\t// Create bash execution message\n\t\t\t\t\tconst bashMessage: BashExecutionMessage = {\n\t\t\t\t\t\trole: \"bashExecution\",\n\t\t\t\t\t\tcommand: input.command,\n\t\t\t\t\t\toutput: result.truncationResult?.content || result.output,\n\t\t\t\t\t\texitCode: result.exitCode,\n\t\t\t\t\t\tcancelled: false,\n\t\t\t\t\t\ttruncated: result.truncationResult?.truncated || false,\n\t\t\t\t\t\tfullOutputPath: result.fullOutputPath,\n\t\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t\t};\n\n\t\t\t\t\t// Add to agent state and save to session\n\t\t\t\t\tagent.appendMessage(bashMessage);\n\t\t\t\t\tsessionManager.saveMessage(bashMessage);\n\n\t\t\t\t\t// Initialize session if needed (same logic as message_end handler)\n\t\t\t\t\tif (sessionManager.shouldInitializeSession(agent.state.messages)) {\n\t\t\t\t\t\tsessionManager.startSession(agent.state);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Emit bash_end event with the message\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"bash_end\", message: bashMessage }));\n\t\t\t\t} catch (error: any) {\n\t\t\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: `Bash command failed: ${error.message}` }));\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error: any) {\n\t\t\t// Output error as JSON\n\t\t\tconsole.log(JSON.stringify({ type: \"error\", error: error.message }));\n\t\t}\n\t});\n\n\t// Keep process alive\n\treturn new Promise(() => {});\n}\n\nexport async function main(args: string[]) {\n\tconst parsed = parseArgs(args);\n\n\tif (parsed.help) {\n\t\tprintHelp();\n\t\treturn;\n\t}\n\n\t// Handle --export flag: convert session file to HTML and exit\n\tif (parsed.export) {\n\t\ttry {\n\t\t\t// Use first message as output path if provided\n\t\t\tconst outputPath = parsed.messages.length > 0 ? parsed.messages[0] : undefined;\n\t\t\tconst result = exportFromFile(parsed.export, outputPath);\n\t\t\tconsole.log(`Exported to: ${result}`);\n\t\t\treturn;\n\t\t} catch (error: any) {\n\t\t\tconsole.error(chalk.red(`Error: ${error.message || \"Failed to export session\"}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\t// Validate: RPC mode doesn't support @file arguments\n\tif (parsed.mode === \"rpc\" && parsed.fileArgs.length > 0) {\n\t\tconsole.error(chalk.red(\"Error: @file arguments are not supported in RPC mode\"));\n\t\tprocess.exit(1);\n\t}\n\n\t// Process @file arguments if any\n\tlet initialMessage: string | undefined;\n\tlet initialAttachments: Attachment[] | undefined;\n\n\tif (parsed.fileArgs.length > 0) {\n\t\tconst { textContent, imageAttachments } = processFileArguments(parsed.fileArgs);\n\n\t\t// Combine file content with first plain text message (if any)\n\t\tif (parsed.messages.length > 0) {\n\t\t\tinitialMessage = textContent + parsed.messages[0];\n\t\t\tparsed.messages.shift(); // Remove first message as it's been combined\n\t\t} else {\n\t\t\tinitialMessage = textContent;\n\t\t}\n\n\t\tinitialAttachments = imageAttachments.length > 0 ? imageAttachments : undefined;\n\t}\n\n\t// Initialize theme (before any TUI rendering)\n\tconst settingsManager = new SettingsManager();\n\tconst themeName = settingsManager.getTheme();\n\tinitTheme(themeName);\n\n\t// Setup session manager\n\tconst sessionManager = new SessionManager(parsed.continue && !parsed.resume, parsed.session);\n\n\t// Disable session saving if --no-session flag is set\n\tif (parsed.noSession) {\n\t\tsessionManager.disable();\n\t}\n\n\t// Handle --resume flag: show session selector\n\tif (parsed.resume) {\n\t\tconst selectedSession = await selectSession(sessionManager);\n\t\tif (!selectedSession) {\n\t\t\tconsole.log(chalk.dim(\"No session selected\"));\n\t\t\treturn;\n\t\t}\n\t\t// Set the selected session as the active session\n\t\tsessionManager.setSessionFile(selectedSession);\n\t}\n\n\t// Resolve model scope early if provided (needed for initial model selection)\n\tlet scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\tif (parsed.models && parsed.models.length > 0) {\n\t\tscopedModels = await resolveModelScope(parsed.models);\n\t}\n\n\t// Determine initial model using priority system:\n\t// 1. CLI args (--provider and --model)\n\t// 2. First model from --models scope\n\t// 3. Restored from session (if --continue or --resume)\n\t// 4. Saved default from settings.json\n\t// 5. First available model with valid API key\n\t// 6. null (allowed in interactive mode)\n\tlet initialModel: Model | null = null;\n\tlet initialThinking: ThinkingLevel = \"off\";\n\n\tif (parsed.provider && parsed.model) {\n\t\t// 1. CLI args take priority\n\t\tconst { model, error } = findModel(parsed.provider, parsed.model);\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tif (!model) {\n\t\t\tconsole.error(chalk.red(`Model ${parsed.provider}/${parsed.model} not found`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t\tinitialModel = model;\n\t} else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {\n\t\t// 2. Use first model from --models scope (skip if continuing/resuming session)\n\t\tinitialModel = scopedModels[0].model;\n\t\tinitialThinking = scopedModels[0].thinkingLevel;\n\t} else if (parsed.continue || parsed.resume) {\n\t\t// 3. Restore from session (will be handled below after loading session)\n\t\t// Leave initialModel as null for now\n\t}\n\n\tif (!initialModel) {\n\t\t// 3. Try saved default from settings\n\t\tconst defaultProvider = settingsManager.getDefaultProvider();\n\t\tconst defaultModel = settingsManager.getDefaultModel();\n\t\tif (defaultProvider && defaultModel) {\n\t\t\tconst { model, error } = findModel(defaultProvider, defaultModel);\n\t\t\tif (error) {\n\t\t\t\tconsole.error(chalk.red(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t\tinitialModel = model;\n\n\t\t\t// Also load saved thinking level if we're using saved model\n\t\t\tconst savedThinking = settingsManager.getDefaultThinkingLevel();\n\t\t\tif (savedThinking) {\n\t\t\t\tinitialThinking = savedThinking;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (!initialModel) {\n\t\t// 4. Try first available model with valid API key\n\t\t// Prefer default model for each provider if available\n\t\tconst { models: availableModels, error } = await getAvailableModels();\n\n\t\tif (error) {\n\t\t\tconsole.error(chalk.red(error));\n\t\t\tprocess.exit(1);\n\t\t}\n\n\t\tif (availableModels.length > 0) {\n\t\t\t// Try to find a default model from known providers\n\t\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\t\tconst defaultModelId = defaultModelPerProvider[provider];\n\t\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);\n\t\t\t\tif (match) {\n\t\t\t\t\tinitialModel = match;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If no default found, use first available\n\t\t\tif (!initialModel) {\n\t\t\t\tinitialModel = availableModels[0];\n\t\t\t}\n\t\t}\n\t}\n\n\t// Determine mode early to know if we should print messages and fail early\n\t// Interactive mode: no --print flag and no --mode flag\n\t// Having initial messages doesn't make it non-interactive anymore\n\tconst isInteractive = !parsed.print && parsed.mode === undefined;\n\tconst mode = parsed.mode || \"text\";\n\t// Only print informational messages in interactive mode\n\t// Non-interactive modes (-p, --mode json, --mode rpc) should be silent except for output\n\tconst shouldPrintMessages = isInteractive;\n\n\t// Non-interactive mode: fail early if no model available\n\tif (!isInteractive && !initialModel) {\n\t\tconsole.error(chalk.red(\"No models available.\"));\n\t\tconsole.error(chalk.yellow(\"\\nSet an API key environment variable:\"));\n\t\tconsole.error(\" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.\");\n\t\tconsole.error(chalk.yellow(`\\nOr create ${getModelsPath()}`));\n\t\tprocess.exit(1);\n\t}\n\n\t// Non-interactive mode: validate API key exists\n\tif (!isInteractive && initialModel) {\n\t\tconst apiKey = parsed.apiKey || (await getApiKeyForModel(initialModel));\n\t\tif (!apiKey) {\n\t\t\tconsole.error(chalk.red(`No API key found for ${initialModel.provider}`));\n\t\t\tprocess.exit(1);\n\t\t}\n\t}\n\n\tconst systemPrompt = buildSystemPrompt(parsed.systemPrompt, parsed.tools, parsed.appendSystemPrompt);\n\n\t// Load previous messages if continuing or resuming\n\t// This may update initialModel if restoring from session\n\tif (parsed.continue || parsed.resume) {\n\t\t// Load and restore model (overrides initialModel if found and has API key)\n\t\tconst savedModel = sessionManager.loadModel();\n\t\tif (savedModel) {\n\t\t\tconst { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);\n\n\t\t\tif (error) {\n\t\t\t\tconsole.error(chalk.red(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\n\t\t\t// Check if restored model exists and has a valid API key\n\t\t\tconst hasApiKey = restoredModel ? !!(await getApiKeyForModel(restoredModel)) : false;\n\n\t\t\tif (restoredModel && hasApiKey) {\n\t\t\t\tinitialModel = restoredModel;\n\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\tconsole.log(chalk.dim(`Restored model: ${savedModel.provider}/${savedModel.modelId}`));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Model not found or no API key - fall back to default selection\n\t\t\t\tconst reason = !restoredModel ? \"model no longer exists\" : \"no API key available\";\n\n\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\tconsole.error(\n\t\t\t\t\t\tchalk.yellow(\n\t\t\t\t\t\t\t`Warning: Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}).`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Ensure we have a valid model - use the same fallback logic\n\t\t\t\tif (!initialModel) {\n\t\t\t\t\tconst { models: availableModels, error: availableError } = await getAvailableModels();\n\t\t\t\t\tif (availableError) {\n\t\t\t\t\t\tconsole.error(chalk.red(availableError));\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t\tif (availableModels.length > 0) {\n\t\t\t\t\t\t// Try to find a default model from known providers\n\t\t\t\t\t\tfor (const provider of Object.keys(defaultModelPerProvider) as KnownProvider[]) {\n\t\t\t\t\t\t\tconst defaultModelId = defaultModelPerProvider[provider];\n\t\t\t\t\t\t\tconst match = availableModels.find((m) => m.provider === provider && m.id === defaultModelId);\n\t\t\t\t\t\t\tif (match) {\n\t\t\t\t\t\t\t\tinitialModel = match;\n\t\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If no default found, use first available\n\t\t\t\t\t\tif (!initialModel) {\n\t\t\t\t\t\t\tinitialModel = availableModels[0];\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (initialModel && shouldPrintMessages) {\n\t\t\t\t\t\t\tconsole.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// No models available at all\n\t\t\t\t\t\tif (shouldPrintMessages) {\n\t\t\t\t\t\t\tconsole.error(chalk.red(\"\\nNo models available.\"));\n\t\t\t\t\t\t\tconsole.error(chalk.yellow(\"Set an API key environment variable:\"));\n\t\t\t\t\t\t\tconsole.error(\" ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, etc.\");\n\t\t\t\t\t\t\tconsole.error(chalk.yellow(`\\nOr create ${getModelsPath()}`));\n\t\t\t\t\t\t}\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t} else if (shouldPrintMessages) {\n\t\t\t\t\tconsole.log(chalk.dim(`Falling back to: ${initialModel.provider}/${initialModel.id}`));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// CLI --thinking flag takes highest priority\n\tif (parsed.thinking) {\n\t\tinitialThinking = parsed.thinking;\n\t}\n\n\t// Determine which tools to use\n\tconst selectedTools = parsed.tools ? parsed.tools.map((name) => allTools[name]) : codingTools;\n\n\t// Create agent (initialModel can be null in interactive mode)\n\tconst agent = new Agent({\n\t\tinitialState: {\n\t\t\tsystemPrompt,\n\t\t\tmodel: initialModel as any, // Can be null\n\t\t\tthinkingLevel: initialThinking,\n\t\t\ttools: selectedTools,\n\t\t},\n\t\tmessageTransformer,\n\t\tqueueMode: settingsManager.getQueueMode(),\n\t\ttransport: new ProviderTransport({\n\t\t\t// Dynamic API key lookup based on current model's provider\n\t\t\tgetApiKey: async () => {\n\t\t\t\tconst currentModel = agent.state.model;\n\t\t\t\tif (!currentModel) {\n\t\t\t\t\tthrow new Error(\"No model selected\");\n\t\t\t\t}\n\n\t\t\t\t// Try CLI override first\n\t\t\t\tif (parsed.apiKey) {\n\t\t\t\t\treturn parsed.apiKey;\n\t\t\t\t}\n\n\t\t\t\t// Use model-specific key lookup\n\t\t\t\tconst key = await getApiKeyForModel(currentModel);\n\t\t\t\tif (!key) {\n\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t`No API key found for provider \"${currentModel.provider}\". Please set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\treturn key;\n\t\t\t},\n\t\t}),\n\t});\n\n\t// If initial thinking was requested but model doesn't support it, silently reset to off\n\tif (initialThinking !== \"off\" && initialModel && !initialModel.reasoning) {\n\t\tagent.setThinkingLevel(\"off\");\n\t}\n\n\t// Track if we had to fall back from saved model (to show in chat later)\n\tlet modelFallbackMessage: string | null = null;\n\n\t// Load previous messages if continuing or resuming\n\tif (parsed.continue || parsed.resume) {\n\t\tconst messages = sessionManager.loadMessages();\n\t\tif (messages.length > 0) {\n\t\t\tagent.replaceMessages(messages);\n\t\t}\n\n\t\t// Load and restore thinking level\n\t\tconst thinkingLevel = sessionManager.loadThinkingLevel() as ThinkingLevel;\n\t\tif (thinkingLevel) {\n\t\t\tagent.setThinkingLevel(thinkingLevel);\n\t\t\tif (shouldPrintMessages) {\n\t\t\t\tconsole.log(chalk.dim(`Restored thinking level: ${thinkingLevel}`));\n\t\t\t}\n\t\t}\n\n\t\t// Check if we had to fall back from saved model\n\t\tconst savedModel = sessionManager.loadModel();\n\t\tif (savedModel && initialModel) {\n\t\t\tconst savedMatches = initialModel.provider === savedModel.provider && initialModel.id === savedModel.modelId;\n\t\t\tif (!savedMatches) {\n\t\t\t\tconst { model: restoredModel, error } = findModel(savedModel.provider, savedModel.modelId);\n\t\t\t\tif (error) {\n\t\t\t\t\t// Config error - already shown above, just use generic message\n\t\t\t\t\tmodelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId}. Using ${initialModel.provider}/${initialModel.id}.`;\n\t\t\t\t} else {\n\t\t\t\t\tconst reason = !restoredModel ? \"model no longer exists\" : \"no API key available\";\n\t\t\t\t\tmodelFallbackMessage = `Could not restore model ${savedModel.provider}/${savedModel.modelId} (${reason}). Using ${initialModel.provider}/${initialModel.id}.`;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Log loaded context files (they're already in the system prompt)\n\tif (shouldPrintMessages && !parsed.continue && !parsed.resume) {\n\t\tconst contextFiles = loadProjectContextFiles();\n\t\tif (contextFiles.length > 0) {\n\t\t\tconsole.log(chalk.dim(\"Loaded project context from:\"));\n\t\t\tfor (const { path: filePath } of contextFiles) {\n\t\t\t\tconsole.log(chalk.dim(` - ${filePath}`));\n\t\t\t}\n\t\t}\n\t}\n\n\t// Route to appropriate mode\n\tif (mode === \"rpc\") {\n\t\t// RPC mode - headless operation\n\t\tawait runRpcMode(agent, sessionManager, settingsManager);\n\t} else if (isInteractive) {\n\t\t// Check for new version in the background (don't block startup)\n\t\tconst versionCheckPromise = checkForNewVersion(VERSION).catch(() => null);\n\n\t\t// Check if we should show changelog (only in interactive mode, only for new sessions)\n\t\tlet changelogMarkdown: string | null = null;\n\t\tif (!parsed.continue && !parsed.resume) {\n\t\t\tconst lastVersion = settingsManager.getLastChangelogVersion();\n\n\t\t\t// Check if we need to show changelog\n\t\t\tif (!lastVersion) {\n\t\t\t\t// First run - show all entries\n\t\t\t\tconst changelogPath = getChangelogPath();\n\t\t\t\tconst entries = parseChangelog(changelogPath);\n\t\t\t\tif (entries.length > 0) {\n\t\t\t\t\tchangelogMarkdown = entries.map((e) => e.content).join(\"\\n\\n\");\n\t\t\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Parse current and last versions\n\t\t\t\tconst changelogPath = getChangelogPath();\n\t\t\t\tconst entries = parseChangelog(changelogPath);\n\t\t\t\tconst newEntries = getNewEntries(entries, lastVersion);\n\n\t\t\t\tif (newEntries.length > 0) {\n\t\t\t\t\tchangelogMarkdown = newEntries.map((e) => e.content).join(\"\\n\\n\");\n\t\t\t\t\tsettingsManager.setLastChangelogVersion(VERSION);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Show model scope if provided\n\t\tif (scopedModels.length > 0) {\n\t\t\tconst modelList = scopedModels\n\t\t\t\t.map((sm) => {\n\t\t\t\t\tconst thinkingStr = sm.thinkingLevel !== \"off\" ? `:${sm.thinkingLevel}` : \"\";\n\t\t\t\t\treturn `${sm.model.id}${thinkingStr}`;\n\t\t\t\t})\n\t\t\t\t.join(\", \");\n\t\t\tconsole.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray(\"(Ctrl+P to cycle)\")}`));\n\t\t}\n\n\t\t// Ensure fd tool is available for file autocomplete\n\t\tconst fdPath = await ensureTool(\"fd\");\n\n\t\t// Interactive mode - use TUI (may have initial messages from CLI args)\n\t\tconst collapseChangelog = settingsManager.getCollapseChangelog();\n\t\tawait runInteractiveMode(\n\t\t\tagent,\n\t\t\tsessionManager,\n\t\t\tsettingsManager,\n\t\t\tVERSION,\n\t\t\tchangelogMarkdown,\n\t\t\tcollapseChangelog,\n\t\t\tmodelFallbackMessage,\n\t\t\tversionCheckPromise,\n\t\t\tscopedModels,\n\t\t\tparsed.messages,\n\t\t\tinitialMessage,\n\t\t\tinitialAttachments,\n\t\t\tfdPath,\n\t\t);\n\t} else {\n\t\t// Non-interactive mode (--print flag or --mode flag)\n\t\tawait runSingleShotMode(agent, sessionManager, parsed.messages, mode, initialMessage, initialAttachments);\n\t}\n}\n"}],"isError":false,"timestamp":1765233669393}}
+{"type":"message","timestamp":"2025-12-08T22:41:09.403Z","message":{"role":"toolResult","toolCallId":"toolu_018AGG1WjGWVfUR2Sibzkh2Q","toolName":"read","content":[{"type":"text","text":"import { randomBytes } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport { createWriteStream, type WriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport * as path from \"node:path\";\nimport { join } from \"node:path\";\nimport type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from \"@oh-my-pi/pi-agent-core\";\nimport type { AssistantMessage, Message, Model } from \"@oh-my-pi/pi-ai\";\nimport type { SlashCommand } from \"@oh-my-pi/pi-tui\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tInput,\n\tLoader,\n\tMarkdown,\n\tProcessTerminal,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\tTUI,\n\tvisibleWidth,\n} from \"@oh-my-pi/pi-tui\";\nimport { exec, spawn } from \"child_process\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, parseChangelog } from \"../changelog.js\";\nimport { copyToClipboard } from \"../clipboard.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"../compaction.js\";\nimport { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from \"../config.js\";\nimport { exportSessionToHtml } from \"../export-html.js\";\nimport { type BashExecutionMessage, isBashExecutionMessage } from \"../messages.js\";\nimport { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from \"../model-config.js\";\nimport { listOAuthProviders, login, logout, type SupportedOAuthProvider } from \"../oauth/index.js\";\nimport {\n\tgetLatestCompactionEntry,\n\tloadSessionFromEntries,\n\ttype SessionManager,\n\tSUMMARY_PREFIX,\n\tSUMMARY_SUFFIX,\n} from \"../session-manager.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { getShellConfig, killProcessTree, sanitizeBinaryOutput } from \"../shell.js\";\nimport { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from \"../slash-commands.js\";\nimport { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from \"../theme/theme.js\";\nimport { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from \"../tools/truncate.js\";\nimport { AssistantMessageComponent } from \"./assistant-message.js\";\nimport { BashExecutionComponent } from \"./bash-execution.js\";\nimport { CompactionComponent } from \"./compaction.js\";\nimport { CustomEditor } from \"./custom-editor.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { FooterComponent } from \"./footer.js\";\nimport { ModelSelectorComponent } from \"./model-selector.js\";\nimport { OAuthSelectorComponent } from \"./oauth-selector.js\";\nimport { QueueModeSelectorComponent } from \"./queue-mode-selector.js\";\nimport { SessionSelectorComponent } from \"./session-selector.js\";\nimport { ThemeSelectorComponent } from \"./theme-selector.js\";\nimport { ThinkingSelectorComponent } from \"./thinking-selector.js\";\nimport { ToolExecutionComponent } from \"./tool-execution.js\";\nimport { UserMessageComponent } from \"./user-message.js\";\nimport { UserMessageSelectorComponent } from \"./user-message-selector.js\";\n\n/**\n * TUI renderer for the coding agent\n */\nexport class TuiRenderer {\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate editor: CustomEditor;\n\tprivate editorContainer: Container; // Container to swap between editor and selector\n\tprivate footer: FooterComponent;\n\tprivate agent: Agent;\n\tprivate sessionManager: SessionManager;\n\tprivate settingsManager: SettingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | null = null;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | null = null;\n\tprivate collapseChangelog = false;\n\n\t// Message queueing\n\tprivate queuedMessages: string[] = [];\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | null = null;\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map();\n\n\t// Thinking level selector\n\tprivate thinkingSelector: ThinkingSelectorComponent | null = null;\n\n\t// Queue mode selector\n\tprivate queueModeSelector: QueueModeSelectorComponent | null = null;\n\n\t// Theme selector\n\tprivate themeSelector: ThemeSelectorComponent | null = null;\n\n\t// Model selector\n\tprivate modelSelector: ModelSelectorComponent | null = null;\n\n\t// User message selector (for branching)\n\tprivate userMessageSelector: UserMessageSelectorComponent | null = null;\n\n\t// Session selector (for resume)\n\tprivate sessionSelector: SessionSelectorComponent | null = null;\n\n\t// OAuth selector\n\tprivate oauthSelector: any | null = null;\n\n\t// Track if this is the first user message (to skip spacer)\n\tprivate isFirstUserMessage = true;\n\n\t// Model scope for quick cycling\n\tprivate scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\n\t// File-based slash commands\n\tprivate fileCommands: FileSlashCommand[] = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Track running bash command process for cancellation\n\tprivate bashProcess: ReturnType | null = null;\n\n\t// Track current bash execution component\n\tprivate bashComponent: BashExecutionComponent | null = null;\n\n\tconstructor(\n\t\tagent: Agent,\n\t\tsessionManager: SessionManager,\n\t\tsettingsManager: SettingsManager,\n\t\tversion: string,\n\t\tchangelogMarkdown: string | null = null,\n\t\tcollapseChangelog = false,\n\t\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\t\tfdPath: string | null = null,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.sessionManager = sessionManager;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.version = version;\n\t\tthis.changelogMarkdown = changelogMarkdown;\n\t\tthis.collapseChangelog = collapseChangelog;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.editor = new CustomEditor(getEditorTheme());\n\t\tthis.editorContainer = new Container(); // Container to hold editor or selector\n\t\tthis.editorContainer.addChild(this.editor); // Start with editor\n\t\tthis.footer = new FooterComponent(agent.state);\n\t\tthis.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());\n\n\t\t// Define slash commands\n\t\tconst thinkingCommand: SlashCommand = {\n\t\t\tname: \"thinking\",\n\t\t\tdescription: \"Select reasoning level (opens selector UI)\",\n\t\t};\n\n\t\tconst modelCommand: SlashCommand = {\n\t\t\tname: \"model\",\n\t\t\tdescription: \"Select model (opens selector UI)\",\n\t\t};\n\n\t\tconst exportCommand: SlashCommand = {\n\t\t\tname: \"export\",\n\t\t\tdescription: \"Export session to HTML file\",\n\t\t};\n\n\t\tconst copyCommand: SlashCommand = {\n\t\t\tname: \"copy\",\n\t\t\tdescription: \"Copy last agent message to clipboard\",\n\t\t};\n\n\t\tconst sessionCommand: SlashCommand = {\n\t\t\tname: \"session\",\n\t\t\tdescription: \"Show session info and stats\",\n\t\t};\n\n\t\tconst changelogCommand: SlashCommand = {\n\t\t\tname: \"changelog\",\n\t\t\tdescription: \"Show changelog entries\",\n\t\t};\n\n\t\tconst branchCommand: SlashCommand = {\n\t\t\tname: \"branch\",\n\t\t\tdescription: \"Create a new branch from a previous message\",\n\t\t};\n\n\t\tconst loginCommand: SlashCommand = {\n\t\t\tname: \"login\",\n\t\t\tdescription: \"Login with OAuth provider\",\n\t\t};\n\n\t\tconst logoutCommand: SlashCommand = {\n\t\t\tname: \"logout\",\n\t\t\tdescription: \"Logout from OAuth provider\",\n\t\t};\n\n\t\tconst queueCommand: SlashCommand = {\n\t\t\tname: \"queue\",\n\t\t\tdescription: \"Select message queue mode (opens selector UI)\",\n\t\t};\n\n\t\tconst themeCommand: SlashCommand = {\n\t\t\tname: \"theme\",\n\t\t\tdescription: \"Select color theme (opens selector UI)\",\n\t\t};\n\n\t\tconst clearCommand: SlashCommand = {\n\t\t\tname: \"clear\",\n\t\t\tdescription: \"Clear context and start a fresh session\",\n\t\t};\n\n\t\tconst compactCommand: SlashCommand = {\n\t\t\tname: \"compact\",\n\t\t\tdescription: \"Manually compact the session context\",\n\t\t};\n\n\t\tconst autocompactCommand: SlashCommand = {\n\t\t\tname: \"autocompact\",\n\t\t\tdescription: \"Toggle automatic context compaction\",\n\t\t};\n\n\t\tconst resumeCommand: SlashCommand = {\n\t\t\tname: \"resume\",\n\t\t\tdescription: \"Resume a different session\",\n\t\t};\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = settingsManager.getHideThinkingBlock();\n\n\t\t// Load file-based slash commands\n\t\tthis.fileCommands = loadSlashCommands();\n\n\t\t// Convert file commands to SlashCommand format\n\t\tconst fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: cmd.description,\n\t\t}));\n\n\t\t// Setup autocomplete for file paths and slash commands\n\t\tconst autocompleteProvider = new CombinedAutocompleteProvider(\n\t\t\t[\n\t\t\t\tthinkingCommand,\n\t\t\t\tmodelCommand,\n\t\t\t\tthemeCommand,\n\t\t\t\texportCommand,\n\t\t\t\tcopyCommand,\n\t\t\t\tsessionCommand,\n\t\t\t\tchangelogCommand,\n\t\t\t\tbranchCommand,\n\t\t\t\tloginCommand,\n\t\t\t\tlogoutCommand,\n\t\t\t\tqueueCommand,\n\t\t\t\tclearCommand,\n\t\t\t\tcompactCommand,\n\t\t\t\tautocompactCommand,\n\t\t\t\tresumeCommand,\n\t\t\t\t...fileSlashCommands,\n\t\t\t],\n\t\t\tprocess.cwd(),\n\t\t\tfdPath,\n\t\t);\n\t\tthis.editor.setAutocompleteProvider(autocompleteProvider);\n\t}\n\n\tasync init(): Promise {\n\t\tif (this.isInitialized) return;\n\n\t\t// Add header with logo and instructions\n\t\tconst logo = theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\t\tconst instructions =\n\t\t\ttheme.fg(\"dim\", \"esc\") +\n\t\t\ttheme.fg(\"muted\", \" to interrupt\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c\") +\n\t\t\ttheme.fg(\"muted\", \" to clear\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c twice\") +\n\t\t\ttheme.fg(\"muted\", \" to exit\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+k\") +\n\t\t\ttheme.fg(\"muted\", \" to delete line\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"shift+tab\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+p\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle models\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+o\") +\n\t\t\ttheme.fg(\"muted\", \" to expand tools\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+t\") +\n\t\t\ttheme.fg(\"muted\", \" to toggle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"/\") +\n\t\t\ttheme.fg(\"muted\", \" for commands\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"!\") +\n\t\t\ttheme.fg(\"muted\", \" to run bash\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"drop files\") +\n\t\t\ttheme.fg(\"muted\", \" to attach\");\n\t\tconst header = new Text(logo + \"\\n\" + instructions, 1, 0);\n\n\t\t// Setup UI layout\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(header);\n\t\tthis.ui.addChild(new Spacer(1));\n\n\t\t// Add changelog if provided\n\t\tif (this.changelogMarkdown) {\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t\tif (this.collapseChangelog) {\n\t\t\t\t// Show condensed version with hint to use /changelog\n\t\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\t\tthis.ui.addChild(new Text(condensedText, 1, 0));\n\t\t\t} else {\n\t\t\t\tthis.ui.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t\tthis.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t}\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(this.editorContainer); // Use container that can hold editor or selector\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\t// Set up custom key handlers on the editor\n\t\tthis.editor.onEscape = () => {\n\t\t\t// Intercept Escape key when processing\n\t\t\tif (this.loadingAnimation) {\n\t\t\t\t// Get all queued messages\n\t\t\t\tconst queuedText = this.queuedMessages.join(\"\\n\\n\");\n\n\t\t\t\t// Get current editor text\n\t\t\t\tconst currentText = this.editor.getText();\n\n\t\t\t\t// Combine: queued messages + current editor text\n\t\t\t\tconst combinedText = [queuedText, currentText].filter((t) => t.trim()).join(\"\\n\\n\");\n\n\t\t\t\t// Put back in editor\n\t\t\t\tthis.editor.setText(combinedText);\n\n\t\t\t\t// Clear queued messages\n\t\t\t\tthis.queuedMessages = [];\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Clear agent's queue too\n\t\t\t\tthis.agent.clearMessageQueue();\n\n\t\t\t\t// Abort\n\t\t\t\tthis.agent.abort();\n\t\t\t} else if (this.bashProcess) {\n\t\t\t\t// Kill running bash command\n\t\t\t\tif (this.bashProcess.pid) {\n\t\t\t\t\tkillProcessTree(this.bashProcess.pid);\n\t\t\t\t}\n\t\t\t\tthis.bashProcess = null;\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\t// Cancel bash mode and clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /branch\n\t\t\t\tconst now = Date.now();\n\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\tthis.lastEscapeTime = 0; // Reset to prevent triple-escape\n\t\t\t\t} else {\n\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tthis.editor.onCtrlC = () => {\n\t\t\tthis.handleCtrlC();\n\t\t};\n\n\t\tthis.editor.onShiftTab = () => {\n\t\t\tthis.cycleThinkingLevel();\n\t\t};\n\n\t\tthis.editor.onCtrlP = () => {\n\t\t\tthis.cycleModel();\n\t\t};\n\n\t\tthis.editor.onCtrlO = () => {\n\t\t\tthis.toggleToolOutputExpansion();\n\t\t};\n\n\t\tthis.editor.onCtrlT = () => {\n\t\t\tthis.toggleThinkingBlockVisibility();\n\t\t};\n\n\t\t// Handle editor text changes for bash mode detection\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\n\t\t// Handle editor submission\n\t\tthis.editor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Check for /thinking command\n\t\t\tif (text === \"/thinking\") {\n\t\t\t\t// Show thinking level selector\n\t\t\t\tthis.showThinkingSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /model command\n\t\t\tif (text === \"/model\") {\n\t\t\t\t// Show model selector\n\t\t\t\tthis.showModelSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /export command\n\t\t\tif (text.startsWith(\"/export\")) {\n\t\t\t\tthis.handleExportCommand(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /copy command\n\t\t\tif (text === \"/copy\") {\n\t\t\t\tthis.handleCopyCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /session command\n\t\t\tif (text === \"/session\") {\n\t\t\t\tthis.handleSessionCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /changelog command\n\t\t\tif (text === \"/changelog\") {\n\t\t\t\tthis.handleChangelogCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /branch command\n\t\t\tif (text === \"/branch\") {\n\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /login command\n\t\t\tif (text === \"/login\") {\n\t\t\t\tthis.showOAuthSelector(\"login\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /logout command\n\t\t\tif (text === \"/logout\") {\n\t\t\t\tthis.showOAuthSelector(\"logout\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /queue command\n\t\t\tif (text === \"/queue\") {\n\t\t\t\tthis.showQueueModeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /theme command\n\t\t\tif (text === \"/theme\") {\n\t\t\t\tthis.showThemeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /clear command\n\t\t\tif (text === \"/clear\") {\n\t\t\t\tthis.handleClearCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /compact command\n\t\t\tif (text === \"/compact\" || text.startsWith(\"/compact \")) {\n\t\t\t\tconst customInstructions = text.startsWith(\"/compact \") ? text.slice(9).trim() : undefined;\n\t\t\t\tthis.handleCompactCommand(customInstructions);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /autocompact command\n\t\t\tif (text === \"/autocompact\") {\n\t\t\t\tthis.handleAutocompactCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /debug command\n\t\t\tif (text === \"/debug\") {\n\t\t\t\tthis.handleDebugCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /resume command\n\t\t\tif (text === \"/resume\") {\n\t\t\t\tthis.showSessionSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for bash command (!)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst command = text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\t// Block if bash already running\n\t\t\t\t\tif (this.bashProcess) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\t// Restore text since editor clears on submit\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\t\tthis.editor.addToHistory(text);\n\t\t\t\t\tthis.handleBashCommand(command);\n\t\t\t\t\t// Reset bash mode since editor is now empty\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for file-based slash commands\n\t\t\ttext = expandSlashCommand(text, this.fileCommands);\n\n\t\t\t// Normal message submission - validate model and API key first\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tif (!currentModel) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t\"No model selected.\\n\\n\" +\n\t\t\t\t\t\t\"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\\n\" +\n\t\t\t\t\t\t`or create ${getModelsPath()}\\n\\n` +\n\t\t\t\t\t\t\"Then use /model to select a model.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Validate API key (async)\n\t\t\tconst apiKey = await getApiKeyForModel(currentModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t`No API key found for ${currentModel.provider}.\\n\\n` +\n\t\t\t\t\t\t`Set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t);\n\t\t\t\tthis.editor.setText(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check if agent is currently streaming\n\t\t\tif (this.agent.state.isStreaming) {\n\t\t\t\t// Queue the message instead of submitting\n\t\t\t\tthis.queuedMessages.push(text);\n\n\t\t\t\t// Queue in agent\n\t\t\t\tawait this.agent.queueMessage({\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\n\t\t\t\t// Update pending messages display\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\tthis.editor.addToHistory(text);\n\n\t\t\t\t// Clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// All good, proceed with submission\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\n\t\t\t// Add to history for up/down arrow navigation\n\t\t\tthis.editor.addToHistory(text);\n\t\t};\n\n\t\t// Start the UI\n\t\tthis.ui.start();\n\t\tthis.isInitialized = true;\n\n\t\t// Subscribe to agent events for UI updates and session saving\n\t\tthis.subscribeToAgent();\n\n\t\t// Set up theme file watcher for live reload\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher\n\t\tthis.footer.watchBranch(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.agent.subscribe(async (event) => {\n\t\t\t// Handle UI updates\n\t\t\tawait this.handleEvent(event, this.agent.state);\n\n\t\t\t// Save messages to session\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tthis.sessionManager.saveMessage(event.message);\n\n\t\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\t\tif (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {\n\t\t\t\t\tthis.sessionManager.startSession(this.agent.state);\n\t\t\t\t}\n\n\t\t\t\t// Check for auto-compaction after assistant messages\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\tawait this.checkAutoCompaction();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate async checkAutoCompaction(): Promise {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message from agent state\n\t\tconst messages = this.agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = this.agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tawait this.executeCompaction(undefined, true);\n\t}\n\n\tprivate async handleEvent(event: AgentEvent, state: AgentState): Promise {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\t// Update footer with current stats\n\t\tthis.footer.updateState(state);\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\t// Show loading animation\n\t\t\t\t// Note: Don't disable submit - we handle queuing in onSubmit callback\n\t\t\t\t// Stop old loader before clearing\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t}\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.loadingAnimation = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\"Working... (esc to interrupt)\",\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\t// Check if this is a queued message\n\t\t\t\t\tconst userMsg = event.message;\n\t\t\t\t\tconst textBlocks =\n\t\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\t\tconst messageText = textBlocks.map((c) => c.text).join(\"\");\n\n\t\t\t\t\tconst queuedIndex = this.queuedMessages.indexOf(messageText);\n\t\t\t\t\tif (queuedIndex !== -1) {\n\t\t\t\t\t\t// Remove from queued messages\n\t\t\t\t\t\tthis.queuedMessages.splice(queuedIndex, 1);\n\t\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Show user message immediately and clear editor\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\t// Create assistant component for streaming\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(event.message as AssistantMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\t// Update streaming component\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// Create tool execution components as soon as we see tool calls\n\t\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\t// Only create if we haven't created it yet\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(\"\", 0, 0));\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// Update existing component with latest arguments as they stream\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\t// Skip user messages (already shown in message_start)\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\n\t\t\t\t\t// Update streaming component with final message (includes stopReason)\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// If message was aborted or errored, mark all pending tool components as failed\n\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\" ? \"Operation aborted\" : assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\tfor (const [toolCallId, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Keep the streaming component - it's now the final assistant message\n\t\t\t\t\tthis.streamingComponent = null;\n\n\t\t\t\t\t// Invalidate footer cache to refresh git branch (in case agent executed git commands)\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\t// Component should already exist from message_update, but create if missing\n\t\t\t\tif (!this.pendingTools.has(event.toolCallId)) {\n\t\t\t\t\tconst component = new ToolExecutionComponent(event.toolName, event.args);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Update the existing tool component with the result\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\t// Convert result to the format expected by updateResult\n\t\t\t\t\tconst resultData =\n\t\t\t\t\t\ttypeof event.result === \"string\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: event.result }],\n\t\t\t\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\tcontent: event.result.content,\n\t\t\t\t\t\t\t\t\tdetails: event.result.details,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\tcomponent.updateResult(resultData);\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\t// Stop loading animation\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = null;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = null;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// Note: Don't need to re-enable submit - we never disable it\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate addMessageToChat(message: Message | AppMessage): void {\n\t\t// Handle bash execution messages\n\t\tif (isBashExecutionMessage(message)) {\n\t\t\tconst bashMsg = message as BashExecutionMessage;\n\t\t\tconst component = new BashExecutionComponent(bashMsg.command, this.ui);\n\t\t\tif (bashMsg.output) {\n\t\t\t\tcomponent.appendOutput(bashMsg.output);\n\t\t\t}\n\t\t\tcomponent.setComplete(\n\t\t\t\tbashMsg.exitCode,\n\t\t\t\tbashMsg.cancelled,\n\t\t\t\tbashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\tbashMsg.fullOutputPath,\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(component);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.role === \"user\") {\n\t\t\tconst userMsg = message;\n\t\t\t// Extract text content from content blocks\n\t\t\tconst textBlocks =\n\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\tif (textContent) {\n\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t}\n\t\t} else if (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message;\n\n\t\t\t// Add assistant message component\n\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t}\n\t\t// Note: tool calls and results are now handled via tool_execution_start/end events\n\t}\n\n\trenderInitialMessages(state: AgentState): void {\n\t\t// Render all existing messages (for --continue mode)\n\t\t// Reset first user message flag for initial render\n\t\tthis.isFirstUserMessage = true;\n\n\t\t// Update footer with loaded state\n\t\tthis.footer.updateState(state);\n\n\t\t// Update editor border color based on current thinking level\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\t// Render messages\n\t\tfor (let i = 0; i < state.messages.length; i++) {\n\t\t\tconst message = state.messages[i];\n\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\t// Create tool execution components for any tool calls\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\t// If message was aborted/errored, immediately mark tool as failed\n\t\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\"\n\t\t\t\t\t\t\t\t\t? \"Operation aborted\"\n\t\t\t\t\t\t\t\t\t: assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Store in map so we can update with results later\n\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Update existing tool execution component with results\t\t\t\t;\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\t// Remove from pending map since it's complete\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Clear pending tools after rendering initial messages\n\t\tthis.pendingTools.clear();\n\n\t\t// Populate editor history with user messages from the session (oldest first so newest is at index 0)\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof message.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t\t\t: message.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\t// Skip compaction summary messages\n\t\t\t\tif (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {\n\t\t\t\t\tthis.editor.addToHistory(textContent);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tasync getUserInput(): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\t// Reset state and re-render messages from agent state\n\t\tthis.isFirstUserMessage = true;\n\t\tthis.pendingTools.clear();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\tfor (const message of this.agent.state.messages) {\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.pendingTools.clear();\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleCtrlC(): void {\n\t\t// Handle Ctrl+C double-press logic\n\t\tconst now = Date.now();\n\t\tconst timeSinceLastCtrlC = now - this.lastSigintTime;\n\n\t\tif (timeSinceLastCtrlC < 500) {\n\t\t\t// Second Ctrl+C within 500ms - exit\n\t\t\tthis.stop();\n\t\t\tprocess.exit(0);\n\t\t} else {\n\t\t\t// First Ctrl+C - clear the editor\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.agent.state.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\t// Only cycle if model supports thinking\n\t\tif (!this.agent.state.model?.reasoning) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Current model does not support thinking\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// xhigh is only available for codex-max models\n\t\tconst modelId = this.agent.state.model?.id || \"\";\n\t\tconst supportsXhigh = modelId.includes(\"codex-max\");\n\t\tconst levels: ThinkingLevel[] = supportsXhigh\n\t\t\t? [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]\n\t\t\t: [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\t\tconst currentLevel = this.agent.state.thinkingLevel || \"off\";\n\t\tconst currentIndex = levels.indexOf(currentLevel);\n\t\tconst nextIndex = (currentIndex + 1) % levels.length;\n\t\tconst nextLevel = levels[nextIndex];\n\n\t\t// Apply the new thinking level\n\t\tthis.agent.setThinkingLevel(nextLevel);\n\n\t\t// Save thinking level change to session and settings\n\t\tthis.sessionManager.saveThinkingLevelChange(nextLevel);\n\t\tthis.settingsManager.setDefaultThinkingLevel(nextLevel);\n\n\t\t// Update border color\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Show brief notification\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking level: ${nextLevel}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async cycleModel(): Promise {\n\t\t// Use scoped models if available, otherwise all available models\n\t\tif (this.scopedModels.length > 0) {\n\t\t\t// Use scoped models with thinking levels\n\t\t\tif (this.scopedModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model in scope\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = this.scopedModels.findIndex(\n\t\t\t\t(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % this.scopedModels.length;\n\t\t\tconst nextEntry = this.scopedModels[nextIndex];\n\t\t\tconst nextModel = nextEntry.model;\n\t\t\tconst nextThinking = nextEntry.thinkingLevel;\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Apply thinking level (silently use \"off\" if model doesn't support thinking)\n\t\t\tconst effectiveThinking = nextModel.reasoning ? nextThinking : \"off\";\n\t\t\tthis.agent.setThinkingLevel(effectiveThinking);\n\t\t\tthis.sessionManager.saveThinkingLevelChange(effectiveThinking);\n\t\t\tthis.settingsManager.setDefaultThinkingLevel(effectiveThinking);\n\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tconst thinkingStr = nextModel.reasoning && nextThinking !== \"off\" ? ` (thinking: ${nextThinking})` : \"\";\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),\n\t\t\t);\n\t\t\tthis.ui.requestRender();\n\t\t} else {\n\t\t\t// Fallback to all available models (no thinking level changes)\n\t\t\tconst { models: availableModels, error } = await getAvailableModels();\n\t\t\tif (error) {\n\t\t\t\tthis.showError(`Failed to load models: ${error}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 0) {\n\t\t\t\tthis.showError(\"No models available to cycle\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model available\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = availableModels.findIndex(\n\t\t\t\t(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % availableModels.length;\n\t\t\tconst nextModel = availableModels[nextIndex];\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool execution, compaction, and bash execution components\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Update all assistant message components and rebuild their content\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\t}\n\t\t}\n\n\t\t// Rebuild chat to apply visibility change\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// Show brief notification\n\t\tconst status = this.hideThinkingBlock ? \"hidden\" : \"visible\";\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking blocks: ${status}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\t// Show error message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\t// Show warning message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"warning\", `Warning: ${warningMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\t// Show new version notification in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\ttheme.bold(theme.fg(\"warning\", \"Update Available\")) +\n\t\t\t\t\t\"\\n\" +\n\t\t\t\t\ttheme.fg(\"muted\", `New version ${newVersion} is available. Run: `) +\n\t\t\t\t\ttheme.fg(\"accent\", \"npm install -g @oh-my-pi/pi-coding-agent\"),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showThinkingSelector(): void {\n\t\t// Create thinking selector with current level\n\t\tthis.thinkingSelector = new ThinkingSelectorComponent(\n\t\t\tthis.agent.state.thinkingLevel,\n\t\t\t(level) => {\n\t\t\t\t// Apply the selected thinking level\n\t\t\t\tthis.agent.setThinkingLevel(level);\n\n\t\t\t\t// Save thinking level change to session and settings\n\t\t\t\tthis.sessionManager.saveThinkingLevelChange(level);\n\t\t\t\tthis.settingsManager.setDefaultThinkingLevel(level);\n\n\t\t\t\t// Update border color\n\t\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Thinking level: ${level}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.thinkingSelector);\n\t\tthis.ui.setFocus(this.thinkingSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThinkingSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.thinkingSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showQueueModeSelector(): void {\n\t\t// Create queue mode selector with current mode\n\t\tthis.queueModeSelector = new QueueModeSelectorComponent(\n\t\t\tthis.agent.getQueueMode(),\n\t\t\t(mode) => {\n\t\t\t\t// Apply the selected queue mode\n\t\t\t\tthis.agent.setQueueMode(mode);\n\n\t\t\t\t// Save queue mode to settings\n\t\t\t\tthis.settingsManager.setQueueMode(mode);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Queue mode: ${mode}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.queueModeSelector);\n\t\tthis.ui.setFocus(this.queueModeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideQueueModeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.queueModeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showThemeSelector(): void {\n\t\t// Get current theme from settings\n\t\tconst currentTheme = this.settingsManager.getTheme() || \"dark\";\n\n\t\t// Create theme selector\n\t\tthis.themeSelector = new ThemeSelectorComponent(\n\t\t\tcurrentTheme,\n\t\t\t(themeName) => {\n\t\t\t\t// Apply the selected theme\n\t\t\t\tconst result = setTheme(themeName);\n\n\t\t\t\t// Save theme to settings\n\t\t\t\tthis.settingsManager.setTheme(themeName);\n\n\t\t\t\t// Invalidate all components to clear cached rendering\n\t\t\t\tthis.ui.invalidate();\n\n\t\t\t\t// Show confirmation or error message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tif (result.success) {\n\t\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Theme: ${themeName}`), 1, 0);\n\t\t\t\t\tthis.chatContainer.addChild(confirmText);\n\t\t\t\t} else {\n\t\t\t\t\tconst errorText = new Text(\n\t\t\t\t\t\ttheme.fg(\"error\", `Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`),\n\t\t\t\t\t\t1,\n\t\t\t\t\t\t0,\n\t\t\t\t\t);\n\t\t\t\t\tthis.chatContainer.addChild(errorText);\n\t\t\t\t}\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t(themeName) => {\n\t\t\t\t// Preview theme on selection change\n\t\t\t\tconst result = setTheme(themeName);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\t// If failed, theme already fell back to dark, just don't re-render\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.themeSelector);\n\t\tthis.ui.setFocus(this.themeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThemeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.themeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showModelSelector(): void {\n\t\t// Create model selector with current model\n\t\tthis.modelSelector = new ModelSelectorComponent(\n\t\t\tthis.ui,\n\t\t\tthis.agent.state.model,\n\t\t\tthis.settingsManager,\n\t\t\t(model) => {\n\t\t\t\t// Apply the selected model\n\t\t\t\tthis.agent.setModel(model);\n\n\t\t\t\t// Save model change to session\n\t\t\t\tthis.sessionManager.saveModelChange(model.provider, model.id);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Model: ${model.id}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.modelSelector);\n\t\tthis.ui.setFocus(this.modelSelector);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideModelSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.modelSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\t// Read from session file directly to see ALL historical user messages\n\t\t// (including those before compaction events)\n\t\tconst entries = this.sessionManager.loadEntries();\n\t\tconst userMessages: Array<{ index: number; text: string }> = [];\n\n\t\tconst getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {\n\t\t\tif (typeof content === \"string\") return content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\treturn content\n\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t.join(\"\");\n\t\t\t}\n\t\t\treturn \"\";\n\t\t};\n\n\t\tfor (let i = 0; i < entries.length; i++) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tif (entry.message.role !== \"user\") continue;\n\n\t\t\tconst textContent = getUserMessageText(entry.message.content);\n\t\t\tif (textContent) {\n\t\t\t\tuserMessages.push({ index: i, text: textContent });\n\t\t\t}\n\t\t}\n\n\t\t// Don't show selector if there are no messages or only one message\n\t\tif (userMessages.length <= 1) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No messages to branch from\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// Create user message selector\n\t\tthis.userMessageSelector = new UserMessageSelectorComponent(\n\t\t\tuserMessages,\n\t\t\t(entryIndex) => {\n\t\t\t\t// Get the selected user message text to put in the editor\n\t\t\t\tconst selectedEntry = entries[entryIndex];\n\t\t\t\tif (selectedEntry.type !== \"message\") return;\n\t\t\t\tif (selectedEntry.message.role !== \"user\") return;\n\n\t\t\t\tconst selectedText = getUserMessageText(selectedEntry.message.content);\n\n\t\t\t\t// Create a branched session by copying entries up to (but not including) the selected entry\n\t\t\t\tconst newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);\n\n\t\t\t\t// Set the new session file as active\n\t\t\t\tthis.sessionManager.setSessionFile(newSessionFile);\n\n\t\t\t\t// Reload the session\n\t\t\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\t\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t\t\t// Clear and re-render the chat\n\t\t\t\tthis.chatContainer.clear();\n\t\t\t\tthis.isFirstUserMessage = true;\n\t\t\t\tthis.renderInitialMessages(this.agent.state);\n\n\t\t\t\t// Show confirmation message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Branched to new session\"), 1, 0));\n\n\t\t\t\t// Put the selected message in the editor\n\n[Showing lines 1-1603 of 2377 (50.0KB limit). Use offset=1604 to continue]"}],"details":{"truncation":{"content":"import { randomBytes } from \"node:crypto\";\nimport * as fs from \"node:fs\";\nimport { createWriteStream, type WriteStream } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport * as path from \"node:path\";\nimport { join } from \"node:path\";\nimport type { Agent, AgentEvent, AgentState, AppMessage, ThinkingLevel } from \"@oh-my-pi/pi-agent-core\";\nimport type { AssistantMessage, Message, Model } from \"@oh-my-pi/pi-ai\";\nimport type { SlashCommand } from \"@oh-my-pi/pi-tui\";\nimport {\n\tCombinedAutocompleteProvider,\n\tContainer,\n\tInput,\n\tLoader,\n\tMarkdown,\n\tProcessTerminal,\n\tSpacer,\n\tText,\n\tTruncatedText,\n\tTUI,\n\tvisibleWidth,\n} from \"@oh-my-pi/pi-tui\";\nimport { exec, spawn } from \"child_process\";\nimport stripAnsi from \"strip-ansi\";\nimport { getChangelogPath, parseChangelog } from \"../changelog.js\";\nimport { copyToClipboard } from \"../clipboard.js\";\nimport { calculateContextTokens, compact, shouldCompact } from \"../compaction.js\";\nimport { APP_NAME, getDebugLogPath, getModelsPath, getOAuthPath } from \"../config.js\";\nimport { exportSessionToHtml } from \"../export-html.js\";\nimport { type BashExecutionMessage, isBashExecutionMessage } from \"../messages.js\";\nimport { getApiKeyForModel, getAvailableModels, invalidateOAuthCache } from \"../model-config.js\";\nimport { listOAuthProviders, login, logout, type SupportedOAuthProvider } from \"../oauth/index.js\";\nimport {\n\tgetLatestCompactionEntry,\n\tloadSessionFromEntries,\n\ttype SessionManager,\n\tSUMMARY_PREFIX,\n\tSUMMARY_SUFFIX,\n} from \"../session-manager.js\";\nimport type { SettingsManager } from \"../settings-manager.js\";\nimport { getShellConfig, killProcessTree, sanitizeBinaryOutput } from \"../shell.js\";\nimport { expandSlashCommand, type FileSlashCommand, loadSlashCommands } from \"../slash-commands.js\";\nimport { getEditorTheme, getMarkdownTheme, onThemeChange, setTheme, theme } from \"../theme/theme.js\";\nimport { DEFAULT_MAX_BYTES, type TruncationResult, truncateTail } from \"../tools/truncate.js\";\nimport { AssistantMessageComponent } from \"./assistant-message.js\";\nimport { BashExecutionComponent } from \"./bash-execution.js\";\nimport { CompactionComponent } from \"./compaction.js\";\nimport { CustomEditor } from \"./custom-editor.js\";\nimport { DynamicBorder } from \"./dynamic-border.js\";\nimport { FooterComponent } from \"./footer.js\";\nimport { ModelSelectorComponent } from \"./model-selector.js\";\nimport { OAuthSelectorComponent } from \"./oauth-selector.js\";\nimport { QueueModeSelectorComponent } from \"./queue-mode-selector.js\";\nimport { SessionSelectorComponent } from \"./session-selector.js\";\nimport { ThemeSelectorComponent } from \"./theme-selector.js\";\nimport { ThinkingSelectorComponent } from \"./thinking-selector.js\";\nimport { ToolExecutionComponent } from \"./tool-execution.js\";\nimport { UserMessageComponent } from \"./user-message.js\";\nimport { UserMessageSelectorComponent } from \"./user-message-selector.js\";\n\n/**\n * TUI renderer for the coding agent\n */\nexport class TuiRenderer {\n\tprivate ui: TUI;\n\tprivate chatContainer: Container;\n\tprivate pendingMessagesContainer: Container;\n\tprivate statusContainer: Container;\n\tprivate editor: CustomEditor;\n\tprivate editorContainer: Container; // Container to swap between editor and selector\n\tprivate footer: FooterComponent;\n\tprivate agent: Agent;\n\tprivate sessionManager: SessionManager;\n\tprivate settingsManager: SettingsManager;\n\tprivate version: string;\n\tprivate isInitialized = false;\n\tprivate onInputCallback?: (text: string) => void;\n\tprivate loadingAnimation: Loader | null = null;\n\n\tprivate lastSigintTime = 0;\n\tprivate lastEscapeTime = 0;\n\tprivate changelogMarkdown: string | null = null;\n\tprivate collapseChangelog = false;\n\n\t// Message queueing\n\tprivate queuedMessages: string[] = [];\n\n\t// Streaming message tracking\n\tprivate streamingComponent: AssistantMessageComponent | null = null;\n\n\t// Tool execution tracking: toolCallId -> component\n\tprivate pendingTools = new Map();\n\n\t// Thinking level selector\n\tprivate thinkingSelector: ThinkingSelectorComponent | null = null;\n\n\t// Queue mode selector\n\tprivate queueModeSelector: QueueModeSelectorComponent | null = null;\n\n\t// Theme selector\n\tprivate themeSelector: ThemeSelectorComponent | null = null;\n\n\t// Model selector\n\tprivate modelSelector: ModelSelectorComponent | null = null;\n\n\t// User message selector (for branching)\n\tprivate userMessageSelector: UserMessageSelectorComponent | null = null;\n\n\t// Session selector (for resume)\n\tprivate sessionSelector: SessionSelectorComponent | null = null;\n\n\t// OAuth selector\n\tprivate oauthSelector: any | null = null;\n\n\t// Track if this is the first user message (to skip spacer)\n\tprivate isFirstUserMessage = true;\n\n\t// Model scope for quick cycling\n\tprivate scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [];\n\n\t// Tool output expansion state\n\tprivate toolOutputExpanded = false;\n\n\t// Thinking block visibility state\n\tprivate hideThinkingBlock = false;\n\n\t// Agent subscription unsubscribe function\n\tprivate unsubscribe?: () => void;\n\n\t// File-based slash commands\n\tprivate fileCommands: FileSlashCommand[] = [];\n\n\t// Track if editor is in bash mode (text starts with !)\n\tprivate isBashMode = false;\n\n\t// Track running bash command process for cancellation\n\tprivate bashProcess: ReturnType | null = null;\n\n\t// Track current bash execution component\n\tprivate bashComponent: BashExecutionComponent | null = null;\n\n\tconstructor(\n\t\tagent: Agent,\n\t\tsessionManager: SessionManager,\n\t\tsettingsManager: SettingsManager,\n\t\tversion: string,\n\t\tchangelogMarkdown: string | null = null,\n\t\tcollapseChangelog = false,\n\t\tscopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }> = [],\n\t\tfdPath: string | null = null,\n\t) {\n\t\tthis.agent = agent;\n\t\tthis.sessionManager = sessionManager;\n\t\tthis.settingsManager = settingsManager;\n\t\tthis.version = version;\n\t\tthis.changelogMarkdown = changelogMarkdown;\n\t\tthis.collapseChangelog = collapseChangelog;\n\t\tthis.scopedModels = scopedModels;\n\t\tthis.ui = new TUI(new ProcessTerminal());\n\t\tthis.chatContainer = new Container();\n\t\tthis.pendingMessagesContainer = new Container();\n\t\tthis.statusContainer = new Container();\n\t\tthis.editor = new CustomEditor(getEditorTheme());\n\t\tthis.editorContainer = new Container(); // Container to hold editor or selector\n\t\tthis.editorContainer.addChild(this.editor); // Start with editor\n\t\tthis.footer = new FooterComponent(agent.state);\n\t\tthis.footer.setAutoCompactEnabled(this.settingsManager.getCompactionEnabled());\n\n\t\t// Define slash commands\n\t\tconst thinkingCommand: SlashCommand = {\n\t\t\tname: \"thinking\",\n\t\t\tdescription: \"Select reasoning level (opens selector UI)\",\n\t\t};\n\n\t\tconst modelCommand: SlashCommand = {\n\t\t\tname: \"model\",\n\t\t\tdescription: \"Select model (opens selector UI)\",\n\t\t};\n\n\t\tconst exportCommand: SlashCommand = {\n\t\t\tname: \"export\",\n\t\t\tdescription: \"Export session to HTML file\",\n\t\t};\n\n\t\tconst copyCommand: SlashCommand = {\n\t\t\tname: \"copy\",\n\t\t\tdescription: \"Copy last agent message to clipboard\",\n\t\t};\n\n\t\tconst sessionCommand: SlashCommand = {\n\t\t\tname: \"session\",\n\t\t\tdescription: \"Show session info and stats\",\n\t\t};\n\n\t\tconst changelogCommand: SlashCommand = {\n\t\t\tname: \"changelog\",\n\t\t\tdescription: \"Show changelog entries\",\n\t\t};\n\n\t\tconst branchCommand: SlashCommand = {\n\t\t\tname: \"branch\",\n\t\t\tdescription: \"Create a new branch from a previous message\",\n\t\t};\n\n\t\tconst loginCommand: SlashCommand = {\n\t\t\tname: \"login\",\n\t\t\tdescription: \"Login with OAuth provider\",\n\t\t};\n\n\t\tconst logoutCommand: SlashCommand = {\n\t\t\tname: \"logout\",\n\t\t\tdescription: \"Logout from OAuth provider\",\n\t\t};\n\n\t\tconst queueCommand: SlashCommand = {\n\t\t\tname: \"queue\",\n\t\t\tdescription: \"Select message queue mode (opens selector UI)\",\n\t\t};\n\n\t\tconst themeCommand: SlashCommand = {\n\t\t\tname: \"theme\",\n\t\t\tdescription: \"Select color theme (opens selector UI)\",\n\t\t};\n\n\t\tconst clearCommand: SlashCommand = {\n\t\t\tname: \"clear\",\n\t\t\tdescription: \"Clear context and start a fresh session\",\n\t\t};\n\n\t\tconst compactCommand: SlashCommand = {\n\t\t\tname: \"compact\",\n\t\t\tdescription: \"Manually compact the session context\",\n\t\t};\n\n\t\tconst autocompactCommand: SlashCommand = {\n\t\t\tname: \"autocompact\",\n\t\t\tdescription: \"Toggle automatic context compaction\",\n\t\t};\n\n\t\tconst resumeCommand: SlashCommand = {\n\t\t\tname: \"resume\",\n\t\t\tdescription: \"Resume a different session\",\n\t\t};\n\n\t\t// Load hide thinking block setting\n\t\tthis.hideThinkingBlock = settingsManager.getHideThinkingBlock();\n\n\t\t// Load file-based slash commands\n\t\tthis.fileCommands = loadSlashCommands();\n\n\t\t// Convert file commands to SlashCommand format\n\t\tconst fileSlashCommands: SlashCommand[] = this.fileCommands.map((cmd) => ({\n\t\t\tname: cmd.name,\n\t\t\tdescription: cmd.description,\n\t\t}));\n\n\t\t// Setup autocomplete for file paths and slash commands\n\t\tconst autocompleteProvider = new CombinedAutocompleteProvider(\n\t\t\t[\n\t\t\t\tthinkingCommand,\n\t\t\t\tmodelCommand,\n\t\t\t\tthemeCommand,\n\t\t\t\texportCommand,\n\t\t\t\tcopyCommand,\n\t\t\t\tsessionCommand,\n\t\t\t\tchangelogCommand,\n\t\t\t\tbranchCommand,\n\t\t\t\tloginCommand,\n\t\t\t\tlogoutCommand,\n\t\t\t\tqueueCommand,\n\t\t\t\tclearCommand,\n\t\t\t\tcompactCommand,\n\t\t\t\tautocompactCommand,\n\t\t\t\tresumeCommand,\n\t\t\t\t...fileSlashCommands,\n\t\t\t],\n\t\t\tprocess.cwd(),\n\t\t\tfdPath,\n\t\t);\n\t\tthis.editor.setAutocompleteProvider(autocompleteProvider);\n\t}\n\n\tasync init(): Promise {\n\t\tif (this.isInitialized) return;\n\n\t\t// Add header with logo and instructions\n\t\tconst logo = theme.bold(theme.fg(\"accent\", APP_NAME)) + theme.fg(\"dim\", ` v${this.version}`);\n\t\tconst instructions =\n\t\t\ttheme.fg(\"dim\", \"esc\") +\n\t\t\ttheme.fg(\"muted\", \" to interrupt\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c\") +\n\t\t\ttheme.fg(\"muted\", \" to clear\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+c twice\") +\n\t\t\ttheme.fg(\"muted\", \" to exit\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+k\") +\n\t\t\ttheme.fg(\"muted\", \" to delete line\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"shift+tab\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+p\") +\n\t\t\ttheme.fg(\"muted\", \" to cycle models\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+o\") +\n\t\t\ttheme.fg(\"muted\", \" to expand tools\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"ctrl+t\") +\n\t\t\ttheme.fg(\"muted\", \" to toggle thinking\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"/\") +\n\t\t\ttheme.fg(\"muted\", \" for commands\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"!\") +\n\t\t\ttheme.fg(\"muted\", \" to run bash\") +\n\t\t\t\"\\n\" +\n\t\t\ttheme.fg(\"dim\", \"drop files\") +\n\t\t\ttheme.fg(\"muted\", \" to attach\");\n\t\tconst header = new Text(logo + \"\\n\" + instructions, 1, 0);\n\n\t\t// Setup UI layout\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(header);\n\t\tthis.ui.addChild(new Spacer(1));\n\n\t\t// Add changelog if provided\n\t\tif (this.changelogMarkdown) {\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t\tif (this.collapseChangelog) {\n\t\t\t\t// Show condensed version with hint to use /changelog\n\t\t\t\tconst versionMatch = this.changelogMarkdown.match(/##\\s+\\[?(\\d+\\.\\d+\\.\\d+)\\]?/);\n\t\t\t\tconst latestVersion = versionMatch ? versionMatch[1] : this.version;\n\t\t\t\tconst condensedText = `Updated to v${latestVersion}. Use ${theme.bold(\"/changelog\")} to view full changelog.`;\n\t\t\t\tthis.ui.addChild(new Text(condensedText, 1, 0));\n\t\t\t} else {\n\t\t\t\tthis.ui.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t\tthis.ui.addChild(new Markdown(this.changelogMarkdown.trim(), 1, 0, getMarkdownTheme()));\n\t\t\t\tthis.ui.addChild(new Spacer(1));\n\t\t\t}\n\t\t\tthis.ui.addChild(new DynamicBorder());\n\t\t}\n\n\t\tthis.ui.addChild(this.chatContainer);\n\t\tthis.ui.addChild(this.pendingMessagesContainer);\n\t\tthis.ui.addChild(this.statusContainer);\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.ui.addChild(this.editorContainer); // Use container that can hold editor or selector\n\t\tthis.ui.addChild(this.footer);\n\t\tthis.ui.setFocus(this.editor);\n\n\t\t// Set up custom key handlers on the editor\n\t\tthis.editor.onEscape = () => {\n\t\t\t// Intercept Escape key when processing\n\t\t\tif (this.loadingAnimation) {\n\t\t\t\t// Get all queued messages\n\t\t\t\tconst queuedText = this.queuedMessages.join(\"\\n\\n\");\n\n\t\t\t\t// Get current editor text\n\t\t\t\tconst currentText = this.editor.getText();\n\n\t\t\t\t// Combine: queued messages + current editor text\n\t\t\t\tconst combinedText = [queuedText, currentText].filter((t) => t.trim()).join(\"\\n\\n\");\n\n\t\t\t\t// Put back in editor\n\t\t\t\tthis.editor.setText(combinedText);\n\n\t\t\t\t// Clear queued messages\n\t\t\t\tthis.queuedMessages = [];\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Clear agent's queue too\n\t\t\t\tthis.agent.clearMessageQueue();\n\n\t\t\t\t// Abort\n\t\t\t\tthis.agent.abort();\n\t\t\t} else if (this.bashProcess) {\n\t\t\t\t// Kill running bash command\n\t\t\t\tif (this.bashProcess.pid) {\n\t\t\t\t\tkillProcessTree(this.bashProcess.pid);\n\t\t\t\t}\n\t\t\t\tthis.bashProcess = null;\n\t\t\t} else if (this.isBashMode) {\n\t\t\t\t// Cancel bash mode and clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.isBashMode = false;\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t} else if (!this.editor.getText().trim()) {\n\t\t\t\t// Double-escape with empty editor triggers /branch\n\t\t\t\tconst now = Date.now();\n\t\t\t\tif (now - this.lastEscapeTime < 500) {\n\t\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\t\tthis.lastEscapeTime = 0; // Reset to prevent triple-escape\n\t\t\t\t} else {\n\t\t\t\t\tthis.lastEscapeTime = now;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tthis.editor.onCtrlC = () => {\n\t\t\tthis.handleCtrlC();\n\t\t};\n\n\t\tthis.editor.onShiftTab = () => {\n\t\t\tthis.cycleThinkingLevel();\n\t\t};\n\n\t\tthis.editor.onCtrlP = () => {\n\t\t\tthis.cycleModel();\n\t\t};\n\n\t\tthis.editor.onCtrlO = () => {\n\t\t\tthis.toggleToolOutputExpansion();\n\t\t};\n\n\t\tthis.editor.onCtrlT = () => {\n\t\t\tthis.toggleThinkingBlockVisibility();\n\t\t};\n\n\t\t// Handle editor text changes for bash mode detection\n\t\tthis.editor.onChange = (text: string) => {\n\t\t\tconst wasBashMode = this.isBashMode;\n\t\t\tthis.isBashMode = text.trimStart().startsWith(\"!\");\n\t\t\tif (wasBashMode !== this.isBashMode) {\n\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t}\n\t\t};\n\n\t\t// Handle editor submission\n\t\tthis.editor.onSubmit = async (text: string) => {\n\t\t\ttext = text.trim();\n\t\t\tif (!text) return;\n\n\t\t\t// Check for /thinking command\n\t\t\tif (text === \"/thinking\") {\n\t\t\t\t// Show thinking level selector\n\t\t\t\tthis.showThinkingSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /model command\n\t\t\tif (text === \"/model\") {\n\t\t\t\t// Show model selector\n\t\t\t\tthis.showModelSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /export command\n\t\t\tif (text.startsWith(\"/export\")) {\n\t\t\t\tthis.handleExportCommand(text);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /copy command\n\t\t\tif (text === \"/copy\") {\n\t\t\t\tthis.handleCopyCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /session command\n\t\t\tif (text === \"/session\") {\n\t\t\t\tthis.handleSessionCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /changelog command\n\t\t\tif (text === \"/changelog\") {\n\t\t\t\tthis.handleChangelogCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /branch command\n\t\t\tif (text === \"/branch\") {\n\t\t\t\tthis.showUserMessageSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /login command\n\t\t\tif (text === \"/login\") {\n\t\t\t\tthis.showOAuthSelector(\"login\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /logout command\n\t\t\tif (text === \"/logout\") {\n\t\t\t\tthis.showOAuthSelector(\"logout\");\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /queue command\n\t\t\tif (text === \"/queue\") {\n\t\t\t\tthis.showQueueModeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /theme command\n\t\t\tif (text === \"/theme\") {\n\t\t\t\tthis.showThemeSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /clear command\n\t\t\tif (text === \"/clear\") {\n\t\t\t\tthis.handleClearCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /compact command\n\t\t\tif (text === \"/compact\" || text.startsWith(\"/compact \")) {\n\t\t\t\tconst customInstructions = text.startsWith(\"/compact \") ? text.slice(9).trim() : undefined;\n\t\t\t\tthis.handleCompactCommand(customInstructions);\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /autocompact command\n\t\t\tif (text === \"/autocompact\") {\n\t\t\t\tthis.handleAutocompactCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /debug command\n\t\t\tif (text === \"/debug\") {\n\t\t\t\tthis.handleDebugCommand();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for /resume command\n\t\t\tif (text === \"/resume\") {\n\t\t\t\tthis.showSessionSelector();\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check for bash command (!)\n\t\t\tif (text.startsWith(\"!\")) {\n\t\t\t\tconst command = text.slice(1).trim();\n\t\t\t\tif (command) {\n\t\t\t\t\t// Block if bash already running\n\t\t\t\t\tif (this.bashProcess) {\n\t\t\t\t\t\tthis.showWarning(\"A bash command is already running. Press Esc to cancel it first.\");\n\t\t\t\t\t\t// Restore text since editor clears on submit\n\t\t\t\t\t\tthis.editor.setText(text);\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\t\tthis.editor.addToHistory(text);\n\t\t\t\t\tthis.handleBashCommand(command);\n\t\t\t\t\t// Reset bash mode since editor is now empty\n\t\t\t\t\tthis.isBashMode = false;\n\t\t\t\t\tthis.updateEditorBorderColor();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for file-based slash commands\n\t\t\ttext = expandSlashCommand(text, this.fileCommands);\n\n\t\t\t// Normal message submission - validate model and API key first\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tif (!currentModel) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t\"No model selected.\\n\\n\" +\n\t\t\t\t\t\t\"Set an API key (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)\\n\" +\n\t\t\t\t\t\t`or create ${getModelsPath()}\\n\\n` +\n\t\t\t\t\t\t\"Then use /model to select a model.\",\n\t\t\t\t);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Validate API key (async)\n\t\t\tconst apiKey = await getApiKeyForModel(currentModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(\n\t\t\t\t\t`No API key found for ${currentModel.provider}.\\n\\n` +\n\t\t\t\t\t\t`Set the appropriate environment variable or update ${getModelsPath()}`,\n\t\t\t\t);\n\t\t\t\tthis.editor.setText(text);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Check if agent is currently streaming\n\t\t\tif (this.agent.state.isStreaming) {\n\t\t\t\t// Queue the message instead of submitting\n\t\t\t\tthis.queuedMessages.push(text);\n\n\t\t\t\t// Queue in agent\n\t\t\t\tawait this.agent.queueMessage({\n\t\t\t\t\trole: \"user\",\n\t\t\t\t\tcontent: [{ type: \"text\", text }],\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t});\n\n\t\t\t\t// Update pending messages display\n\t\t\t\tthis.updatePendingMessagesDisplay();\n\n\t\t\t\t// Add to history for up/down arrow navigation\n\t\t\t\tthis.editor.addToHistory(text);\n\n\t\t\t\t// Clear editor\n\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// All good, proceed with submission\n\t\t\tif (this.onInputCallback) {\n\t\t\t\tthis.onInputCallback(text);\n\t\t\t}\n\n\t\t\t// Add to history for up/down arrow navigation\n\t\t\tthis.editor.addToHistory(text);\n\t\t};\n\n\t\t// Start the UI\n\t\tthis.ui.start();\n\t\tthis.isInitialized = true;\n\n\t\t// Subscribe to agent events for UI updates and session saving\n\t\tthis.subscribeToAgent();\n\n\t\t// Set up theme file watcher for live reload\n\t\tonThemeChange(() => {\n\t\t\tthis.ui.invalidate();\n\t\t\tthis.updateEditorBorderColor();\n\t\t\tthis.ui.requestRender();\n\t\t});\n\n\t\t// Set up git branch watcher\n\t\tthis.footer.watchBranch(() => {\n\t\t\tthis.ui.requestRender();\n\t\t});\n\t}\n\n\tprivate subscribeToAgent(): void {\n\t\tthis.unsubscribe = this.agent.subscribe(async (event) => {\n\t\t\t// Handle UI updates\n\t\t\tawait this.handleEvent(event, this.agent.state);\n\n\t\t\t// Save messages to session\n\t\t\tif (event.type === \"message_end\") {\n\t\t\t\tthis.sessionManager.saveMessage(event.message);\n\n\t\t\t\t// Check if we should initialize session now (after first user+assistant exchange)\n\t\t\t\tif (this.sessionManager.shouldInitializeSession(this.agent.state.messages)) {\n\t\t\t\t\tthis.sessionManager.startSession(this.agent.state);\n\t\t\t\t}\n\n\t\t\t\t// Check for auto-compaction after assistant messages\n\t\t\t\tif (event.message.role === \"assistant\") {\n\t\t\t\t\tawait this.checkAutoCompaction();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate async checkAutoCompaction(): Promise {\n\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\tif (!settings.enabled) return;\n\n\t\t// Get last non-aborted assistant message from agent state\n\t\tconst messages = this.agent.state.messages;\n\t\tlet lastAssistant: AssistantMessage | null = null;\n\t\tfor (let i = messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = messages[i];\n\t\t\tif (msg.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = msg as AssistantMessage;\n\t\t\t\tif (assistantMsg.stopReason !== \"aborted\") {\n\t\t\t\t\tlastAssistant = assistantMsg;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (!lastAssistant) return;\n\n\t\tconst contextTokens = calculateContextTokens(lastAssistant.usage);\n\t\tconst contextWindow = this.agent.state.model.contextWindow;\n\n\t\tif (!shouldCompact(contextTokens, contextWindow, settings)) return;\n\n\t\t// Trigger auto-compaction\n\t\tawait this.executeCompaction(undefined, true);\n\t}\n\n\tprivate async handleEvent(event: AgentEvent, state: AgentState): Promise {\n\t\tif (!this.isInitialized) {\n\t\t\tawait this.init();\n\t\t}\n\n\t\t// Update footer with current stats\n\t\tthis.footer.updateState(state);\n\n\t\tswitch (event.type) {\n\t\t\tcase \"agent_start\":\n\t\t\t\t// Show loading animation\n\t\t\t\t// Note: Don't disable submit - we handle queuing in onSubmit callback\n\t\t\t\t// Stop old loader before clearing\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t}\n\t\t\t\tthis.statusContainer.clear();\n\t\t\t\tthis.loadingAnimation = new Loader(\n\t\t\t\t\tthis.ui,\n\t\t\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\t\t\t\"Working... (esc to interrupt)\",\n\t\t\t\t);\n\t\t\t\tthis.statusContainer.addChild(this.loadingAnimation);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_start\":\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\t// Check if this is a queued message\n\t\t\t\t\tconst userMsg = event.message;\n\t\t\t\t\tconst textBlocks =\n\t\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\t\tconst messageText = textBlocks.map((c) => c.text).join(\"\");\n\n\t\t\t\t\tconst queuedIndex = this.queuedMessages.indexOf(messageText);\n\t\t\t\t\tif (queuedIndex !== -1) {\n\t\t\t\t\t\t// Remove from queued messages\n\t\t\t\t\t\tthis.queuedMessages.splice(queuedIndex, 1);\n\t\t\t\t\t\tthis.updatePendingMessagesDisplay();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Show user message immediately and clear editor\n\t\t\t\t\tthis.addMessageToChat(event.message);\n\t\t\t\t\tthis.editor.setText(\"\");\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t} else if (event.message.role === \"assistant\") {\n\t\t\t\t\t// Create assistant component for streaming\n\t\t\t\t\tthis.streamingComponent = new AssistantMessageComponent(undefined, this.hideThinkingBlock);\n\t\t\t\t\tthis.chatContainer.addChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent.updateContent(event.message as AssistantMessage);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_update\":\n\t\t\t\t// Update streaming component\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// Create tool execution components as soon as we see tool calls\n\t\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\t\t// Only create if we haven't created it yet\n\t\t\t\t\t\t\tif (!this.pendingTools.has(content.id)) {\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(\"\", 0, 0));\n\t\t\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// Update existing component with latest arguments as they stream\n\t\t\t\t\t\t\t\tconst component = this.pendingTools.get(content.id);\n\t\t\t\t\t\t\t\tif (component) {\n\t\t\t\t\t\t\t\t\tcomponent.updateArgs(content.arguments);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\n\t\t\tcase \"message_end\":\n\t\t\t\t// Skip user messages (already shown in message_start)\n\t\t\t\tif (event.message.role === \"user\") {\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent && event.message.role === \"assistant\") {\n\t\t\t\t\tconst assistantMsg = event.message as AssistantMessage;\n\n\t\t\t\t\t// Update streaming component with final message (includes stopReason)\n\t\t\t\t\tthis.streamingComponent.updateContent(assistantMsg);\n\n\t\t\t\t\t// If message was aborted or errored, mark all pending tool components as failed\n\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\" ? \"Operation aborted\" : assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\tfor (const [toolCallId, component] of this.pendingTools.entries()) {\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\t\t\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Keep the streaming component - it's now the final assistant message\n\t\t\t\t\tthis.streamingComponent = null;\n\n\t\t\t\t\t// Invalidate footer cache to refresh git branch (in case agent executed git commands)\n\t\t\t\t\tthis.footer.invalidate();\n\t\t\t\t}\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\n\t\t\tcase \"tool_execution_start\": {\n\t\t\t\t// Component should already exist from message_update, but create if missing\n\t\t\t\tif (!this.pendingTools.has(event.toolCallId)) {\n\t\t\t\t\tconst component = new ToolExecutionComponent(event.toolName, event.args);\n\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\tthis.pendingTools.set(event.toolCallId, component);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"tool_execution_end\": {\n\t\t\t\t// Update the existing tool component with the result\n\t\t\t\tconst component = this.pendingTools.get(event.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\t// Convert result to the format expected by updateResult\n\t\t\t\t\tconst resultData =\n\t\t\t\t\t\ttypeof event.result === \"string\"\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tcontent: [{ type: \"text\" as const, text: event.result }],\n\t\t\t\t\t\t\t\t\tdetails: undefined,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: {\n\t\t\t\t\t\t\t\t\tcontent: event.result.content,\n\t\t\t\t\t\t\t\t\tdetails: event.result.details,\n\t\t\t\t\t\t\t\t\tisError: event.isError,\n\t\t\t\t\t\t\t\t};\n\t\t\t\t\tcomponent.updateResult(resultData);\n\t\t\t\t\tthis.pendingTools.delete(event.toolCallId);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcase \"agent_end\":\n\t\t\t\t// Stop loading animation\n\t\t\t\tif (this.loadingAnimation) {\n\t\t\t\t\tthis.loadingAnimation.stop();\n\t\t\t\t\tthis.loadingAnimation = null;\n\t\t\t\t\tthis.statusContainer.clear();\n\t\t\t\t}\n\t\t\t\tif (this.streamingComponent) {\n\t\t\t\t\tthis.chatContainer.removeChild(this.streamingComponent);\n\t\t\t\t\tthis.streamingComponent = null;\n\t\t\t\t}\n\t\t\t\tthis.pendingTools.clear();\n\t\t\t\t// Note: Don't need to re-enable submit - we never disable it\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\tbreak;\n\t\t}\n\t}\n\n\tprivate addMessageToChat(message: Message | AppMessage): void {\n\t\t// Handle bash execution messages\n\t\tif (isBashExecutionMessage(message)) {\n\t\t\tconst bashMsg = message as BashExecutionMessage;\n\t\t\tconst component = new BashExecutionComponent(bashMsg.command, this.ui);\n\t\t\tif (bashMsg.output) {\n\t\t\t\tcomponent.appendOutput(bashMsg.output);\n\t\t\t}\n\t\t\tcomponent.setComplete(\n\t\t\t\tbashMsg.exitCode,\n\t\t\t\tbashMsg.cancelled,\n\t\t\t\tbashMsg.truncated ? ({ truncated: true } as TruncationResult) : undefined,\n\t\t\t\tbashMsg.fullOutputPath,\n\t\t\t);\n\t\t\tthis.chatContainer.addChild(component);\n\t\t\treturn;\n\t\t}\n\n\t\tif (message.role === \"user\") {\n\t\t\tconst userMsg = message;\n\t\t\t// Extract text content from content blocks\n\t\t\tconst textBlocks =\n\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\tif (textContent) {\n\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t}\n\t\t} else if (message.role === \"assistant\") {\n\t\t\tconst assistantMsg = message;\n\n\t\t\t// Add assistant message component\n\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\tthis.chatContainer.addChild(assistantComponent);\n\t\t}\n\t\t// Note: tool calls and results are now handled via tool_execution_start/end events\n\t}\n\n\trenderInitialMessages(state: AgentState): void {\n\t\t// Render all existing messages (for --continue mode)\n\t\t// Reset first user message flag for initial render\n\t\tthis.isFirstUserMessage = true;\n\n\t\t// Update footer with loaded state\n\t\tthis.footer.updateState(state);\n\n\t\t// Update editor border color based on current thinking level\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\t// Render messages\n\t\tfor (let i = 0; i < state.messages.length; i++) {\n\t\t\tconst message = state.messages[i];\n\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\t// Create tool execution components for any tool calls\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\n\t\t\t\t\t\t// If message was aborted/errored, immediately mark tool as failed\n\t\t\t\t\t\tif (assistantMsg.stopReason === \"aborted\" || assistantMsg.stopReason === \"error\") {\n\t\t\t\t\t\t\tconst errorMessage =\n\t\t\t\t\t\t\t\tassistantMsg.stopReason === \"aborted\"\n\t\t\t\t\t\t\t\t\t? \"Operation aborted\"\n\t\t\t\t\t\t\t\t\t: assistantMsg.errorMessage || \"Error\";\n\t\t\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\t\t\tcontent: [{ type: \"text\", text: errorMessage }],\n\t\t\t\t\t\t\t\tisError: true,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t// Store in map so we can update with results later\n\t\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\t// Update existing tool execution component with results\t\t\t\t;\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\t// Remove from pending map since it's complete\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\t// Clear pending tools after rendering initial messages\n\t\tthis.pendingTools.clear();\n\n\t\t// Populate editor history with user messages from the session (oldest first so newest is at index 0)\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof message.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: message.content }]\n\t\t\t\t\t\t: message.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\t// Skip compaction summary messages\n\t\t\t\tif (textContent && !textContent.startsWith(SUMMARY_PREFIX)) {\n\t\t\t\t\tthis.editor.addToHistory(textContent);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tasync getUserInput(): Promise {\n\t\treturn new Promise((resolve) => {\n\t\t\tthis.onInputCallback = (text: string) => {\n\t\t\t\tthis.onInputCallback = undefined;\n\t\t\t\tresolve(text);\n\t\t\t};\n\t\t});\n\t}\n\n\tprivate rebuildChatFromMessages(): void {\n\t\t// Reset state and re-render messages from agent state\n\t\tthis.isFirstUserMessage = true;\n\t\tthis.pendingTools.clear();\n\n\t\t// Get compaction info if any\n\t\tconst compactionEntry = getLatestCompactionEntry(this.sessionManager.loadEntries());\n\n\t\tfor (const message of this.agent.state.messages) {\n\t\t\t// Handle bash execution messages\n\t\t\tif (isBashExecutionMessage(message)) {\n\t\t\t\tthis.addMessageToChat(message);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (message.role === \"user\") {\n\t\t\t\tconst userMsg = message;\n\t\t\t\tconst textBlocks =\n\t\t\t\t\ttypeof userMsg.content === \"string\"\n\t\t\t\t\t\t? [{ type: \"text\", text: userMsg.content }]\n\t\t\t\t\t\t: userMsg.content.filter((c) => c.type === \"text\");\n\t\t\t\tconst textContent = textBlocks.map((c) => c.text).join(\"\");\n\t\t\t\tif (textContent) {\n\t\t\t\t\t// Check if this is a compaction summary message\n\t\t\t\t\tif (textContent.startsWith(SUMMARY_PREFIX) && compactionEntry) {\n\t\t\t\t\t\tconst summary = textContent.slice(SUMMARY_PREFIX.length, -SUMMARY_SUFFIX.length);\n\t\t\t\t\t\tconst component = new CompactionComponent(compactionEntry.tokensBefore, summary);\n\t\t\t\t\t\tcomponent.setExpanded(this.toolOutputExpanded);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconst userComponent = new UserMessageComponent(textContent, this.isFirstUserMessage);\n\t\t\t\t\t\tthis.chatContainer.addChild(userComponent);\n\t\t\t\t\t\tthis.isFirstUserMessage = false;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message;\n\t\t\t\tconst assistantComponent = new AssistantMessageComponent(assistantMsg, this.hideThinkingBlock);\n\t\t\t\tthis.chatContainer.addChild(assistantComponent);\n\n\t\t\t\tfor (const content of assistantMsg.content) {\n\t\t\t\t\tif (content.type === \"toolCall\") {\n\t\t\t\t\t\tconst component = new ToolExecutionComponent(content.name, content.arguments);\n\t\t\t\t\t\tthis.chatContainer.addChild(component);\n\t\t\t\t\t\tthis.pendingTools.set(content.id, component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (message.role === \"toolResult\") {\n\t\t\t\tconst component = this.pendingTools.get(message.toolCallId);\n\t\t\t\tif (component) {\n\t\t\t\t\tcomponent.updateResult({\n\t\t\t\t\t\tcontent: message.content,\n\t\t\t\t\t\tdetails: message.details,\n\t\t\t\t\t\tisError: message.isError,\n\t\t\t\t\t});\n\t\t\t\t\tthis.pendingTools.delete(message.toolCallId);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.pendingTools.clear();\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleCtrlC(): void {\n\t\t// Handle Ctrl+C double-press logic\n\t\tconst now = Date.now();\n\t\tconst timeSinceLastCtrlC = now - this.lastSigintTime;\n\n\t\tif (timeSinceLastCtrlC < 500) {\n\t\t\t// Second Ctrl+C within 500ms - exit\n\t\t\tthis.stop();\n\t\t\tprocess.exit(0);\n\t\t} else {\n\t\t\t// First Ctrl+C - clear the editor\n\t\t\tthis.clearEditor();\n\t\t\tthis.lastSigintTime = now;\n\t\t}\n\t}\n\n\tprivate updateEditorBorderColor(): void {\n\t\tif (this.isBashMode) {\n\t\t\tthis.editor.borderColor = theme.getBashModeBorderColor();\n\t\t} else {\n\t\t\tconst level = this.agent.state.thinkingLevel || \"off\";\n\t\t\tthis.editor.borderColor = theme.getThinkingBorderColor(level);\n\t\t}\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate cycleThinkingLevel(): void {\n\t\t// Only cycle if model supports thinking\n\t\tif (!this.agent.state.model?.reasoning) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Current model does not support thinking\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// xhigh is only available for codex-max models\n\t\tconst modelId = this.agent.state.model?.id || \"\";\n\t\tconst supportsXhigh = modelId.includes(\"codex-max\");\n\t\tconst levels: ThinkingLevel[] = supportsXhigh\n\t\t\t? [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\"]\n\t\t\t: [\"off\", \"minimal\", \"low\", \"medium\", \"high\"];\n\t\tconst currentLevel = this.agent.state.thinkingLevel || \"off\";\n\t\tconst currentIndex = levels.indexOf(currentLevel);\n\t\tconst nextIndex = (currentIndex + 1) % levels.length;\n\t\tconst nextLevel = levels[nextIndex];\n\n\t\t// Apply the new thinking level\n\t\tthis.agent.setThinkingLevel(nextLevel);\n\n\t\t// Save thinking level change to session and settings\n\t\tthis.sessionManager.saveThinkingLevelChange(nextLevel);\n\t\tthis.settingsManager.setDefaultThinkingLevel(nextLevel);\n\n\t\t// Update border color\n\t\tthis.updateEditorBorderColor();\n\n\t\t// Show brief notification\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking level: ${nextLevel}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async cycleModel(): Promise {\n\t\t// Use scoped models if available, otherwise all available models\n\t\tif (this.scopedModels.length > 0) {\n\t\t\t// Use scoped models with thinking levels\n\t\t\tif (this.scopedModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model in scope\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = this.scopedModels.findIndex(\n\t\t\t\t(sm) => sm.model.id === currentModel?.id && sm.model.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % this.scopedModels.length;\n\t\t\tconst nextEntry = this.scopedModels[nextIndex];\n\t\t\tconst nextModel = nextEntry.model;\n\t\t\tconst nextThinking = nextEntry.thinkingLevel;\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Apply thinking level (silently use \"off\" if model doesn't support thinking)\n\t\t\tconst effectiveThinking = nextModel.reasoning ? nextThinking : \"off\";\n\t\t\tthis.agent.setThinkingLevel(effectiveThinking);\n\t\t\tthis.sessionManager.saveThinkingLevelChange(effectiveThinking);\n\t\t\tthis.settingsManager.setDefaultThinkingLevel(effectiveThinking);\n\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tconst thinkingStr = nextModel.reasoning && nextThinking !== \"off\" ? ` (thinking: ${nextThinking})` : \"\";\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}${thinkingStr}`), 1, 0),\n\t\t\t);\n\t\t\tthis.ui.requestRender();\n\t\t} else {\n\t\t\t// Fallback to all available models (no thinking level changes)\n\t\t\tconst { models: availableModels, error } = await getAvailableModels();\n\t\t\tif (error) {\n\t\t\t\tthis.showError(`Failed to load models: ${error}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 0) {\n\t\t\t\tthis.showError(\"No models available to cycle\");\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (availableModels.length === 1) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Only one model available\"), 1, 0));\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst currentModel = this.agent.state.model;\n\t\t\tlet currentIndex = availableModels.findIndex(\n\t\t\t\t(m) => m.id === currentModel?.id && m.provider === currentModel?.provider,\n\t\t\t);\n\n\t\t\t// If current model not in scope, start from first\n\t\t\tif (currentIndex === -1) {\n\t\t\t\tcurrentIndex = 0;\n\t\t\t}\n\n\t\t\tconst nextIndex = (currentIndex + 1) % availableModels.length;\n\t\t\tconst nextModel = availableModels[nextIndex];\n\n\t\t\t// Validate API key\n\t\t\tconst apiKey = await getApiKeyForModel(nextModel);\n\t\t\tif (!apiKey) {\n\t\t\t\tthis.showError(`No API key for ${nextModel.provider}/${nextModel.id}`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t// Switch model\n\t\t\tthis.agent.setModel(nextModel);\n\n\t\t\t// Save model change to session and settings\n\t\t\tthis.sessionManager.saveModelChange(nextModel.provider, nextModel.id);\n\t\t\tthis.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id);\n\n\t\t\t// Show notification\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Switched to ${nextModel.name || nextModel.id}`), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\tprivate toggleToolOutputExpansion(): void {\n\t\tthis.toolOutputExpanded = !this.toolOutputExpanded;\n\n\t\t// Update all tool execution, compaction, and bash execution components\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof ToolExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof CompactionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t} else if (child instanceof BashExecutionComponent) {\n\t\t\t\tchild.setExpanded(this.toolOutputExpanded);\n\t\t\t}\n\t\t}\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate toggleThinkingBlockVisibility(): void {\n\t\tthis.hideThinkingBlock = !this.hideThinkingBlock;\n\t\tthis.settingsManager.setHideThinkingBlock(this.hideThinkingBlock);\n\n\t\t// Update all assistant message components and rebuild their content\n\t\tfor (const child of this.chatContainer.children) {\n\t\t\tif (child instanceof AssistantMessageComponent) {\n\t\t\t\tchild.setHideThinkingBlock(this.hideThinkingBlock);\n\t\t\t}\n\t\t}\n\n\t\t// Rebuild chat to apply visibility change\n\t\tthis.chatContainer.clear();\n\t\tthis.rebuildChatFromMessages();\n\n\t\t// Show brief notification\n\t\tconst status = this.hideThinkingBlock ? \"hidden\" : \"visible\";\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Thinking blocks: ${status}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tclearEditor(): void {\n\t\tthis.editor.setText(\"\");\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowError(errorMessage: string): void {\n\t\t// Show error message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"error\", `Error: ${errorMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowWarning(warningMessage: string): void {\n\t\t// Show warning message in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"warning\", `Warning: ${warningMessage}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tshowNewVersionNotification(newVersion: string): void {\n\t\t// Show new version notification in the chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(\n\t\t\t\ttheme.bold(theme.fg(\"warning\", \"Update Available\")) +\n\t\t\t\t\t\"\\n\" +\n\t\t\t\t\ttheme.fg(\"muted\", `New version ${newVersion} is available. Run: `) +\n\t\t\t\t\ttheme.fg(\"accent\", \"npm install -g @oh-my-pi/pi-coding-agent\"),\n\t\t\t\t1,\n\t\t\t\t0,\n\t\t\t),\n\t\t);\n\t\tthis.chatContainer.addChild(new DynamicBorder((text) => theme.fg(\"warning\", text)));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate showThinkingSelector(): void {\n\t\t// Create thinking selector with current level\n\t\tthis.thinkingSelector = new ThinkingSelectorComponent(\n\t\t\tthis.agent.state.thinkingLevel,\n\t\t\t(level) => {\n\t\t\t\t// Apply the selected thinking level\n\t\t\t\tthis.agent.setThinkingLevel(level);\n\n\t\t\t\t// Save thinking level change to session and settings\n\t\t\t\tthis.sessionManager.saveThinkingLevelChange(level);\n\t\t\t\tthis.settingsManager.setDefaultThinkingLevel(level);\n\n\t\t\t\t// Update border color\n\t\t\t\tthis.updateEditorBorderColor();\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Thinking level: ${level}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThinkingSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.thinkingSelector);\n\t\tthis.ui.setFocus(this.thinkingSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThinkingSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.thinkingSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showQueueModeSelector(): void {\n\t\t// Create queue mode selector with current mode\n\t\tthis.queueModeSelector = new QueueModeSelectorComponent(\n\t\t\tthis.agent.getQueueMode(),\n\t\t\t(mode) => {\n\t\t\t\t// Apply the selected queue mode\n\t\t\t\tthis.agent.setQueueMode(mode);\n\n\t\t\t\t// Save queue mode to settings\n\t\t\t\tthis.settingsManager.setQueueMode(mode);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Queue mode: ${mode}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideQueueModeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.queueModeSelector);\n\t\tthis.ui.setFocus(this.queueModeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideQueueModeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.queueModeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showThemeSelector(): void {\n\t\t// Get current theme from settings\n\t\tconst currentTheme = this.settingsManager.getTheme() || \"dark\";\n\n\t\t// Create theme selector\n\t\tthis.themeSelector = new ThemeSelectorComponent(\n\t\t\tcurrentTheme,\n\t\t\t(themeName) => {\n\t\t\t\t// Apply the selected theme\n\t\t\t\tconst result = setTheme(themeName);\n\n\t\t\t\t// Save theme to settings\n\t\t\t\tthis.settingsManager.setTheme(themeName);\n\n\t\t\t\t// Invalidate all components to clear cached rendering\n\t\t\t\tthis.ui.invalidate();\n\n\t\t\t\t// Show confirmation or error message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tif (result.success) {\n\t\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Theme: ${themeName}`), 1, 0);\n\t\t\t\t\tthis.chatContainer.addChild(confirmText);\n\t\t\t\t} else {\n\t\t\t\t\tconst errorText = new Text(\n\t\t\t\t\t\ttheme.fg(\"error\", `Failed to load theme \"${themeName}\": ${result.error}\\nFell back to dark theme.`),\n\t\t\t\t\t\t1,\n\t\t\t\t\t\t0,\n\t\t\t\t\t);\n\t\t\t\t\tthis.chatContainer.addChild(errorText);\n\t\t\t\t}\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideThemeSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t(themeName) => {\n\t\t\t\t// Preview theme on selection change\n\t\t\t\tconst result = setTheme(themeName);\n\t\t\t\tif (result.success) {\n\t\t\t\t\tthis.ui.invalidate();\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t\t// If failed, theme already fell back to dark, just don't re-render\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.themeSelector);\n\t\tthis.ui.setFocus(this.themeSelector.getSelectList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideThemeSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.themeSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showModelSelector(): void {\n\t\t// Create model selector with current model\n\t\tthis.modelSelector = new ModelSelectorComponent(\n\t\t\tthis.ui,\n\t\t\tthis.agent.state.model,\n\t\t\tthis.settingsManager,\n\t\t\t(model) => {\n\t\t\t\t// Apply the selected model\n\t\t\t\tthis.agent.setModel(model);\n\n\t\t\t\t// Save model change to session\n\t\t\t\tthis.sessionManager.saveModelChange(model.provider, model.id);\n\n\t\t\t\t// Show confirmation message with proper spacing\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tconst confirmText = new Text(theme.fg(\"dim\", `Model: ${model.id}`), 1, 0);\n\t\t\t\tthis.chatContainer.addChild(confirmText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideModelSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.modelSelector);\n\t\tthis.ui.setFocus(this.modelSelector);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideModelSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.modelSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showUserMessageSelector(): void {\n\t\t// Read from session file directly to see ALL historical user messages\n\t\t// (including those before compaction events)\n\t\tconst entries = this.sessionManager.loadEntries();\n\t\tconst userMessages: Array<{ index: number; text: string }> = [];\n\n\t\tconst getUserMessageText = (content: string | Array<{ type: string; text?: string }>): string => {\n\t\t\tif (typeof content === \"string\") return content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\treturn content\n\t\t\t\t\t.filter((c): c is { type: \"text\"; text: string } => c.type === \"text\")\n\t\t\t\t\t.map((c) => c.text)\n\t\t\t\t\t.join(\"\");\n\t\t\t}\n\t\t\treturn \"\";\n\t\t};\n\n\t\tfor (let i = 0; i < entries.length; i++) {\n\t\t\tconst entry = entries[i];\n\t\t\tif (entry.type !== \"message\") continue;\n\t\t\tif (entry.message.role !== \"user\") continue;\n\n\t\t\tconst textContent = getUserMessageText(entry.message.content);\n\t\t\tif (textContent) {\n\t\t\t\tuserMessages.push({ index: i, text: textContent });\n\t\t\t}\n\t\t}\n\n\t\t// Don't show selector if there are no messages or only one message\n\t\tif (userMessages.length <= 1) {\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"No messages to branch from\"), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t\treturn;\n\t\t}\n\n\t\t// Create user message selector\n\t\tthis.userMessageSelector = new UserMessageSelectorComponent(\n\t\t\tuserMessages,\n\t\t\t(entryIndex) => {\n\t\t\t\t// Get the selected user message text to put in the editor\n\t\t\t\tconst selectedEntry = entries[entryIndex];\n\t\t\t\tif (selectedEntry.type !== \"message\") return;\n\t\t\t\tif (selectedEntry.message.role !== \"user\") return;\n\n\t\t\t\tconst selectedText = getUserMessageText(selectedEntry.message.content);\n\n\t\t\t\t// Create a branched session by copying entries up to (but not including) the selected entry\n\t\t\t\tconst newSessionFile = this.sessionManager.createBranchedSessionFromEntries(entries, entryIndex);\n\n\t\t\t\t// Set the new session file as active\n\t\t\t\tthis.sessionManager.setSessionFile(newSessionFile);\n\n\t\t\t\t// Reload the session\n\t\t\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\t\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t\t\t// Clear and re-render the chat\n\t\t\t\tthis.chatContainer.clear();\n\t\t\t\tthis.isFirstUserMessage = true;\n\t\t\t\tthis.renderInitialMessages(this.agent.state);\n\n\t\t\t\t// Show confirmation message\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Branched to new session\"), 1, 0));\n\n\t\t\t\t// Put the selected message in the editor","truncated":true,"truncatedBy":"bytes","totalLines":2377,"totalBytes":75229,"outputLines":1603,"outputBytes":51190,"lastLinePartial":false,"firstLineExceedsLimit":false}},"isError":false,"timestamp":1765233669399}}
{"type":"message","timestamp":"2025-12-08T22:41:13.860Z","message":{"role":"assistant","content":[{"type":"text","text":"Let me get the rest of the tui-renderer file:"},{"type":"toolCall","id":"toolu_01KgRZiUs86jzrwZs41sAvEs","name":"read","arguments":{"path":"/Users/badlogic/workspaces/pi-mono/packages/coding-agent/src/tui/tui-renderer.ts","offset":1604}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":1,"output":114,"cacheRead":0,"cacheWrite":33637,"totalTokens":33752,"cost":{"input":0.000005,"output":0.00285,"cacheRead":0,"cacheWrite":0.21023125,"total":0.21308625}},"stopReason":"toolUse","timestamp":1765233669399}}
{"type":"message","timestamp":"2025-12-08T22:41:13.868Z","message":{"role":"toolResult","toolCallId":"toolu_01KgRZiUs86jzrwZs41sAvEs","toolName":"read","content":[{"type":"text","text":"\t\t\t\tthis.editor.setText(selectedText);\n\n\t\t\t\t// Hide selector and show editor again\n\t\t\t\tthis.hideUserMessageSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideUserMessageSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.userMessageSelector);\n\t\tthis.ui.setFocus(this.userMessageSelector.getMessageList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideUserMessageSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.userMessageSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate showSessionSelector(): void {\n\t\t// Create session selector\n\t\tthis.sessionSelector = new SessionSelectorComponent(\n\t\t\tthis.sessionManager,\n\t\t\tasync (sessionPath) => {\n\t\t\t\tthis.hideSessionSelector();\n\t\t\t\tawait this.handleResumeSession(sessionPath);\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Just hide the selector\n\t\t\t\tthis.hideSessionSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.sessionSelector);\n\t\tthis.ui.setFocus(this.sessionSelector.getSessionList());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleResumeSession(sessionPath: string): Promise {\n\t\t// Unsubscribe first to prevent processing events during transition\n\t\tthis.unsubscribe?.();\n\n\t\t// Abort and wait for completion\n\t\tthis.agent.abort();\n\t\tawait this.agent.waitForIdle();\n\n\t\t// Stop loading animation\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = null;\n\t\t}\n\t\tthis.statusContainer.clear();\n\n\t\t// Clear UI state\n\t\tthis.queuedMessages = [];\n\t\tthis.pendingMessagesContainer.clear();\n\t\tthis.streamingComponent = null;\n\t\tthis.pendingTools.clear();\n\n\t\t// Set the selected session as active\n\t\tthis.sessionManager.setSessionFile(sessionPath);\n\n\t\t// Reload the session\n\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t// Restore model if saved in session\n\t\tconst savedModel = this.sessionManager.loadModel();\n\t\tif (savedModel) {\n\t\t\tconst availableModels = (await getAvailableModels()).models;\n\t\t\tconst match = availableModels.find((m) => m.provider === savedModel.provider && m.id === savedModel.modelId);\n\t\t\tif (match) {\n\t\t\t\tthis.agent.setModel(match);\n\t\t\t}\n\t\t}\n\n\t\t// Restore thinking level if saved in session\n\t\tconst savedThinking = this.sessionManager.loadThinkingLevel();\n\t\tif (savedThinking) {\n\t\t\tthis.agent.setThinkingLevel(savedThinking as ThinkingLevel);\n\t\t}\n\n\t\t// Resubscribe to agent\n\t\tthis.subscribeToAgent();\n\n\t\t// Clear and re-render the chat\n\t\tthis.chatContainer.clear();\n\t\tthis.isFirstUserMessage = true;\n\t\tthis.renderInitialMessages(this.agent.state);\n\n\t\t// Show confirmation message\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Resumed session\"), 1, 0));\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideSessionSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.sessionSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate async showOAuthSelector(mode: \"login\" | \"logout\"): Promise {\n\t\t// For logout mode, filter to only show logged-in providers\n\t\tlet providersToShow: string[] = [];\n\t\tif (mode === \"logout\") {\n\t\t\tconst loggedInProviders = listOAuthProviders();\n\t\t\tif (loggedInProviders.length === 0) {\n\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\tthis.chatContainer.addChild(\n\t\t\t\t\tnew Text(theme.fg(\"dim\", \"No OAuth providers logged in. Use /login first.\"), 1, 0),\n\t\t\t\t);\n\t\t\t\tthis.ui.requestRender();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tprovidersToShow = loggedInProviders;\n\t\t}\n\n\t\t// Create OAuth selector\n\t\tthis.oauthSelector = new OAuthSelectorComponent(\n\t\t\tmode,\n\t\t\tasync (providerId: string) => {\n\t\t\t\t// Hide selector first\n\t\t\t\tthis.hideOAuthSelector();\n\n\t\t\t\tif (mode === \"login\") {\n\t\t\t\t\t// Handle login\n\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Logging in to ${providerId}...`), 1, 0));\n\t\t\t\t\tthis.ui.requestRender();\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait login(\n\t\t\t\t\t\t\tproviderId as SupportedOAuthProvider,\n\t\t\t\t\t\t\t(url: string) => {\n\t\t\t\t\t\t\t\t// Show auth URL to user\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"accent\", \"Opening browser to:\"), 1, 0));\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"accent\", url), 1, 0));\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\t\t\tthis.chatContainer.addChild(\n\t\t\t\t\t\t\t\t\tnew Text(theme.fg(\"warning\", \"Paste the authorization code below:\"), 1, 0),\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tthis.ui.requestRender();\n\n\t\t\t\t\t\t\t\t// Open URL in browser\n\t\t\t\t\t\t\t\tconst openCmd =\n\t\t\t\t\t\t\t\t\tprocess.platform === \"darwin\" ? \"open\" : process.platform === \"win32\" ? \"start\" : \"xdg-open\";\n\t\t\t\t\t\t\t\texec(`${openCmd} \"${url}\"`);\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tasync () => {\n\t\t\t\t\t\t\t\t// Prompt for code with a simple Input\n\t\t\t\t\t\t\t\treturn new Promise((resolve) => {\n\t\t\t\t\t\t\t\t\tconst codeInput = new Input();\n\t\t\t\t\t\t\t\t\tcodeInput.onSubmit = () => {\n\t\t\t\t\t\t\t\t\t\tconst code = codeInput.getValue();\n\t\t\t\t\t\t\t\t\t\t// Restore editor\n\t\t\t\t\t\t\t\t\t\tthis.editorContainer.clear();\n\t\t\t\t\t\t\t\t\t\tthis.editorContainer.addChild(this.editor);\n\t\t\t\t\t\t\t\t\t\tthis.ui.setFocus(this.editor);\n\t\t\t\t\t\t\t\t\t\tresolve(code);\n\t\t\t\t\t\t\t\t\t};\n\n\t\t\t\t\t\t\t\t\tthis.editorContainer.clear();\n\t\t\t\t\t\t\t\t\tthis.editorContainer.addChild(codeInput);\n\t\t\t\t\t\t\t\t\tthis.ui.setFocus(codeInput);\n\t\t\t\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\n\t\t\t\t\t\t// Success - invalidate OAuth cache so footer updates\n\t\t\t\t\t\tinvalidateOAuthCache();\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tthis.chatContainer.addChild(\n\t\t\t\t\t\t\tnew Text(theme.fg(\"success\", `✓ Successfully logged in to ${providerId}`), 1, 0),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Tokens saved to ${getOAuthPath()}`), 1, 0));\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t} catch (error: any) {\n\t\t\t\t\t\tthis.showError(`Login failed: ${error.message}`);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Handle logout\n\t\t\t\t\ttry {\n\t\t\t\t\t\tawait logout(providerId as SupportedOAuthProvider);\n\n\t\t\t\t\t\t// Invalidate OAuth cache so footer updates\n\t\t\t\t\t\tinvalidateOAuthCache();\n\t\t\t\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\t\t\t\tthis.chatContainer.addChild(\n\t\t\t\t\t\t\tnew Text(theme.fg(\"success\", `✓ Successfully logged out of ${providerId}`), 1, 0),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tthis.chatContainer.addChild(\n\t\t\t\t\t\t\tnew Text(theme.fg(\"dim\", `Credentials removed from ${getOAuthPath()}`), 1, 0),\n\t\t\t\t\t\t);\n\t\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t\t} catch (error: any) {\n\t\t\t\t\t\tthis.showError(`Logout failed: ${error.message}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t},\n\t\t\t() => {\n\t\t\t\t// Cancel - just hide the selector\n\t\t\t\tthis.hideOAuthSelector();\n\t\t\t\tthis.ui.requestRender();\n\t\t\t},\n\t\t);\n\n\t\t// Replace editor with selector\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.oauthSelector);\n\t\tthis.ui.setFocus(this.oauthSelector);\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate hideOAuthSelector(): void {\n\t\t// Replace selector with editor in the container\n\t\tthis.editorContainer.clear();\n\t\tthis.editorContainer.addChild(this.editor);\n\t\tthis.oauthSelector = null;\n\t\tthis.ui.setFocus(this.editor);\n\t}\n\n\tprivate handleExportCommand(text: string): void {\n\t\t// Parse optional filename from command: /export [filename]\n\t\tconst parts = text.split(/\\s+/);\n\t\tconst outputPath = parts.length > 1 ? parts[1] : undefined;\n\n\t\ttry {\n\t\t\t// Export session to HTML\n\t\t\tconst filePath = exportSessionToHtml(this.sessionManager, this.agent.state, outputPath);\n\n\t\t\t// Show success message in chat - matching thinking level style\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Session exported to: ${filePath}`), 1, 0));\n\t\t\tthis.ui.requestRender();\n\t\t} catch (error: any) {\n\t\t\t// Show error message in chat\n\t\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\t\tthis.chatContainer.addChild(\n\t\t\t\tnew Text(theme.fg(\"error\", `Failed to export session: ${error.message || \"Unknown error\"}`), 1, 0),\n\t\t\t);\n\t\t\tthis.ui.requestRender();\n\t\t}\n\t}\n\n\tprivate handleCopyCommand(): void {\n\t\t// Find the last assistant message\n\t\tconst lastAssistantMessage = this.agent.state.messages\n\t\t\t.slice()\n\t\t\t.reverse()\n\t\t\t.find((m) => m.role === \"assistant\");\n\n\t\tif (!lastAssistantMessage) {\n\t\t\tthis.showError(\"No agent messages to copy yet.\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Extract raw text content from all text blocks\n\t\tlet textContent = \"\";\n\n\t\tfor (const content of lastAssistantMessage.content) {\n\t\t\tif (content.type === \"text\") {\n\t\t\t\ttextContent += content.text;\n\t\t\t}\n\t\t}\n\n\t\tif (!textContent.trim()) {\n\t\t\tthis.showError(\"Last agent message contains no text content.\");\n\t\t\treturn;\n\t\t}\n\n\t\t// Copy to clipboard using cross-platform compatible method\n\t\ttry {\n\t\t\tcopyToClipboard(textContent);\n\t\t} catch (error) {\n\t\t\tthis.showError(error instanceof Error ? error.message : String(error));\n\t\t\treturn;\n\t\t}\n\n\t\t// Show confirmation message\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", \"Copied last agent message to clipboard\"), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleSessionCommand(): void {\n\t\t// Get session info\n\t\tconst sessionFile = this.sessionManager.getSessionFile();\n\t\tconst state = this.agent.state;\n\n\t\t// Count messages\n\t\tconst userMessages = state.messages.filter((m) => m.role === \"user\").length;\n\t\tconst assistantMessages = state.messages.filter((m) => m.role === \"assistant\").length;\n\t\tconst toolResults = state.messages.filter((m) => m.role === \"toolResult\").length;\n\t\tconst totalMessages = state.messages.length;\n\n\t\t// Count tool calls from assistant messages\n\t\tlet toolCalls = 0;\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttoolCalls += assistantMsg.content.filter((c) => c.type === \"toolCall\").length;\n\t\t\t}\n\t\t}\n\n\t\t// Calculate cumulative usage from all assistant messages (same as footer)\n\t\tlet totalInput = 0;\n\t\tlet totalOutput = 0;\n\t\tlet totalCacheRead = 0;\n\t\tlet totalCacheWrite = 0;\n\t\tlet totalCost = 0;\n\n\t\tfor (const message of state.messages) {\n\t\t\tif (message.role === \"assistant\") {\n\t\t\t\tconst assistantMsg = message as AssistantMessage;\n\t\t\t\ttotalInput += assistantMsg.usage.input;\n\t\t\t\ttotalOutput += assistantMsg.usage.output;\n\t\t\t\ttotalCacheRead += assistantMsg.usage.cacheRead;\n\t\t\t\ttotalCacheWrite += assistantMsg.usage.cacheWrite;\n\t\t\t\ttotalCost += assistantMsg.usage.cost.total;\n\t\t\t}\n\t\t}\n\n\t\tconst totalTokens = totalInput + totalOutput + totalCacheRead + totalCacheWrite;\n\n\t\t// Build info text\n\t\tlet info = `${theme.bold(\"Session Info\")}\\n\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"File:\")} ${sessionFile}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"ID:\")} ${this.sessionManager.getSessionId()}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Messages\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"User:\")} ${userMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Assistant:\")} ${assistantMessages}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Calls:\")} ${toolCalls}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Tool Results:\")} ${toolResults}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${totalMessages}\\n\\n`;\n\t\tinfo += `${theme.bold(\"Tokens\")}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Input:\")} ${totalInput.toLocaleString()}\\n`;\n\t\tinfo += `${theme.fg(\"dim\", \"Output:\")} ${totalOutput.toLocaleString()}\\n`;\n\t\tif (totalCacheRead > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Read:\")} ${totalCacheRead.toLocaleString()}\\n`;\n\t\t}\n\t\tif (totalCacheWrite > 0) {\n\t\t\tinfo += `${theme.fg(\"dim\", \"Cache Write:\")} ${totalCacheWrite.toLocaleString()}\\n`;\n\t\t}\n\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${totalTokens.toLocaleString()}\\n`;\n\n\t\tif (totalCost > 0) {\n\t\t\tinfo += `\\n${theme.bold(\"Cost\")}\\n`;\n\t\t\tinfo += `${theme.fg(\"dim\", \"Total:\")} ${totalCost.toFixed(4)}`;\n\t\t}\n\n\t\t// Show info in chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(info, 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleChangelogCommand(): void {\n\t\tconst changelogPath = getChangelogPath();\n\t\tconst allEntries = parseChangelog(changelogPath);\n\n\t\t// Show all entries in reverse order (oldest first, newest last)\n\t\tconst changelogMarkdown =\n\t\t\tallEntries.length > 0\n\t\t\t\t? allEntries\n\t\t\t\t\t\t.reverse()\n\t\t\t\t\t\t.map((e) => e.content)\n\t\t\t\t\t\t.join(\"\\n\\n\")\n\t\t\t\t: \"No changelog entries found.\";\n\n\t\t// Display in chat\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ui.addChild(new Text(theme.bold(theme.fg(\"accent\", \"What's New\")), 1, 0));\n\t\tthis.ui.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Markdown(changelogMarkdown, 1, 1, getMarkdownTheme()));\n\t\tthis.chatContainer.addChild(new DynamicBorder());\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleClearCommand(): Promise {\n\t\t// Unsubscribe first to prevent processing abort events\n\t\tthis.unsubscribe?.();\n\n\t\t// Abort and wait for completion\n\t\tthis.agent.abort();\n\t\tawait this.agent.waitForIdle();\n\n\t\t// Stop loading animation\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = null;\n\t\t}\n\t\tthis.statusContainer.clear();\n\n\t\t// Reset agent and session\n\t\tthis.agent.reset();\n\t\tthis.sessionManager.reset();\n\n\t\t// Resubscribe to agent\n\t\tthis.subscribeToAgent();\n\n\t\t// Clear UI state\n\t\tthis.chatContainer.clear();\n\t\tthis.pendingMessagesContainer.clear();\n\t\tthis.queuedMessages = [];\n\t\tthis.streamingComponent = null;\n\t\tthis.pendingTools.clear();\n\t\tthis.isFirstUserMessage = true;\n\n\t\t// Show confirmation\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(theme.fg(\"accent\", \"✓ Context cleared\") + \"\\n\" + theme.fg(\"muted\", \"Started fresh session\"), 1, 1),\n\t\t);\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate handleDebugCommand(): void {\n\t\t// Force a render and capture all lines with their widths\n\t\tconst width = this.ui.terminal.columns;\n\t\tconst allLines = this.ui.render(width);\n\n\t\tconst debugLogPath = getDebugLogPath();\n\t\tconst debugData = [\n\t\t\t`Debug output at ${new Date().toISOString()}`,\n\t\t\t`Terminal width: ${width}`,\n\t\t\t`Total lines: ${allLines.length}`,\n\t\t\t\"\",\n\t\t\t\"=== All rendered lines with visible widths ===\",\n\t\t\t...allLines.map((line, idx) => {\n\t\t\t\tconst vw = visibleWidth(line);\n\t\t\t\tconst escaped = JSON.stringify(line);\n\t\t\t\treturn `[${idx}] (w=${vw}) ${escaped}`;\n\t\t\t}),\n\t\t\t\"\",\n\t\t\t\"=== Agent messages (JSONL) ===\",\n\t\t\t...this.agent.state.messages.map((msg) => JSON.stringify(msg)),\n\t\t\t\"\",\n\t\t].join(\"\\n\");\n\n\t\tfs.mkdirSync(path.dirname(debugLogPath), { recursive: true });\n\t\tfs.writeFileSync(debugLogPath, debugData);\n\n\t\t// Show confirmation\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(\n\t\t\tnew Text(theme.fg(\"accent\", \"✓ Debug log written\") + \"\\n\" + theme.fg(\"muted\", debugLogPath), 1, 1),\n\t\t);\n\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate async handleBashCommand(command: string): Promise {\n\t\t// Create component and add to chat\n\t\tthis.bashComponent = new BashExecutionComponent(command, this.ui);\n\t\tthis.chatContainer.addChild(this.bashComponent);\n\t\tthis.ui.requestRender();\n\n\t\ttry {\n\t\t\tconst result = await this.executeBashCommand(command, (chunk) => {\n\t\t\t\tif (this.bashComponent) {\n\t\t\t\t\tthis.bashComponent.appendOutput(chunk);\n\t\t\t\t\tthis.ui.requestRender();\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tif (this.bashComponent) {\n\t\t\t\tthis.bashComponent.setComplete(\n\t\t\t\t\tresult.exitCode,\n\t\t\t\t\tresult.cancelled,\n\t\t\t\t\tresult.truncationResult,\n\t\t\t\t\tresult.fullOutputPath,\n\t\t\t\t);\n\n\t\t\t\t// Create and save message (even if cancelled, for consistency with LLM aborts)\n\t\t\t\tconst bashMessage: BashExecutionMessage = {\n\t\t\t\t\trole: \"bashExecution\",\n\t\t\t\t\tcommand,\n\t\t\t\t\toutput: result.truncationResult?.content || this.bashComponent.getOutput(),\n\t\t\t\t\texitCode: result.exitCode,\n\t\t\t\t\tcancelled: result.cancelled,\n\t\t\t\t\ttruncated: result.truncationResult?.truncated || false,\n\t\t\t\t\tfullOutputPath: result.fullOutputPath,\n\t\t\t\t\ttimestamp: Date.now(),\n\t\t\t\t};\n\n\t\t\t\t// Add to agent state\n\t\t\t\tthis.agent.appendMessage(bashMessage);\n\n\t\t\t\t// Save to session\n\t\t\t\tthis.sessionManager.saveMessage(bashMessage);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tconst errorMessage = error instanceof Error ? error.message : \"Unknown error\";\n\t\t\tif (this.bashComponent) {\n\t\t\t\tthis.bashComponent.setComplete(null, false);\n\t\t\t}\n\t\t\tthis.showError(`Bash command failed: ${errorMessage}`);\n\t\t}\n\n\t\tthis.bashComponent = null;\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate executeBashCommand(\n\t\tcommand: string,\n\t\tonChunk: (chunk: string) => void,\n\t): Promise<{\n\t\texitCode: number | null;\n\t\tcancelled: boolean;\n\t\ttruncationResult?: TruncationResult;\n\t\tfullOutputPath?: string;\n\t}> {\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tconst { shell, args } = getShellConfig();\n\t\t\tconst child = spawn(shell, [...args, command], {\n\t\t\t\tdetached: true,\n\t\t\t\tstdio: [\"ignore\", \"pipe\", \"pipe\"],\n\t\t\t});\n\n\t\t\tthis.bashProcess = child;\n\n\t\t\t// Track sanitized output for truncation\n\t\t\tconst outputChunks: string[] = [];\n\t\t\tlet outputBytes = 0;\n\t\t\tconst maxOutputBytes = DEFAULT_MAX_BYTES * 2;\n\n\t\t\t// Temp file for large output\n\t\t\tlet tempFilePath: string | undefined;\n\t\t\tlet tempFileStream: WriteStream | undefined;\n\t\t\tlet totalBytes = 0;\n\n\t\t\tconst handleData = (data: Buffer) => {\n\t\t\t\ttotalBytes += data.length;\n\n\t\t\t\t// Sanitize once at the source: strip ANSI, replace binary garbage, normalize newlines\n\t\t\t\tconst text = sanitizeBinaryOutput(stripAnsi(data.toString())).replace(/\\r/g, \"\");\n\n\t\t\t\t// Start writing to temp file if exceeds threshold\n\t\t\t\tif (totalBytes > DEFAULT_MAX_BYTES && !tempFilePath) {\n\t\t\t\t\tconst id = randomBytes(8).toString(\"hex\");\n\t\t\t\t\ttempFilePath = join(tmpdir(), `pi-bash-${id}.log`);\n\t\t\t\t\ttempFileStream = createWriteStream(tempFilePath);\n\t\t\t\t\tfor (const chunk of outputChunks) {\n\t\t\t\t\t\ttempFileStream.write(chunk);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (tempFileStream) {\n\t\t\t\t\ttempFileStream.write(text);\n\t\t\t\t}\n\n\t\t\t\t// Keep rolling buffer of sanitized text\n\t\t\t\toutputChunks.push(text);\n\t\t\t\toutputBytes += text.length;\n\t\t\t\twhile (outputBytes > maxOutputBytes && outputChunks.length > 1) {\n\t\t\t\t\tconst removed = outputChunks.shift()!;\n\t\t\t\t\toutputBytes -= removed.length;\n\t\t\t\t}\n\n\t\t\t\t// Stream to component\n\t\t\t\tonChunk(text);\n\t\t\t};\n\n\t\t\tchild.stdout?.on(\"data\", handleData);\n\t\t\tchild.stderr?.on(\"data\", handleData);\n\n\t\t\tchild.on(\"close\", (code) => {\n\t\t\t\tif (tempFileStream) {\n\t\t\t\t\ttempFileStream.end();\n\t\t\t\t}\n\n\t\t\t\tthis.bashProcess = null;\n\n\t\t\t\t// Combine buffered chunks for truncation (already sanitized)\n\t\t\t\tconst fullOutput = outputChunks.join(\"\");\n\t\t\t\tconst truncationResult = truncateTail(fullOutput);\n\n\t\t\t\t// code === null means killed (cancelled)\n\t\t\t\tconst cancelled = code === null;\n\n\t\t\t\tresolve({\n\t\t\t\t\texitCode: code,\n\t\t\t\t\tcancelled,\n\t\t\t\t\ttruncationResult: truncationResult.truncated ? truncationResult : undefined,\n\t\t\t\t\tfullOutputPath: tempFilePath,\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tchild.on(\"error\", (err) => {\n\t\t\t\tif (tempFileStream) {\n\t\t\t\t\ttempFileStream.end();\n\t\t\t\t}\n\t\t\t\tthis.bashProcess = null;\n\t\t\t\treject(err);\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate compactionAbortController: AbortController | null = null;\n\n\t/**\n\t * Shared logic to execute context compaction.\n\t * Handles aborting agent, showing loader, performing compaction, updating session/UI.\n\t */\n\tprivate async executeCompaction(customInstructions?: string, isAuto = false): Promise {\n\t\t// Unsubscribe first to prevent processing events during compaction\n\t\tthis.unsubscribe?.();\n\n\t\t// Abort and wait for completion\n\t\tthis.agent.abort();\n\t\tawait this.agent.waitForIdle();\n\n\t\t// Stop loading animation\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = null;\n\t\t}\n\t\tthis.statusContainer.clear();\n\n\t\t// Create abort controller for compaction\n\t\tthis.compactionAbortController = new AbortController();\n\n\t\t// Set up escape handler during compaction\n\t\tconst originalOnEscape = this.editor.onEscape;\n\t\tthis.editor.onEscape = () => {\n\t\t\tif (this.compactionAbortController) {\n\t\t\t\tthis.compactionAbortController.abort();\n\t\t\t}\n\t\t};\n\n\t\t// Show compacting status with loader\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tconst label = isAuto ? \"Auto-compacting context... (esc to cancel)\" : \"Compacting context... (esc to cancel)\";\n\t\tconst compactingLoader = new Loader(\n\t\t\tthis.ui,\n\t\t\t(spinner) => theme.fg(\"accent\", spinner),\n\t\t\t(text) => theme.fg(\"muted\", text),\n\t\t\tlabel,\n\t\t);\n\t\tthis.statusContainer.addChild(compactingLoader);\n\t\tthis.ui.requestRender();\n\n\t\ttry {\n\t\t\t// Get API key for current model\n\t\t\tconst apiKey = await getApiKeyForModel(this.agent.state.model);\n\t\t\tif (!apiKey) {\n\t\t\t\tthrow new Error(`No API key for ${this.agent.state.model.provider}`);\n\t\t\t}\n\n\t\t\t// Perform compaction with abort signal\n\t\t\tconst entries = this.sessionManager.loadEntries();\n\t\t\tconst settings = this.settingsManager.getCompactionSettings();\n\t\t\tconst compactionEntry = await compact(\n\t\t\t\tentries,\n\t\t\t\tthis.agent.state.model,\n\t\t\t\tsettings,\n\t\t\t\tapiKey,\n\t\t\t\tthis.compactionAbortController.signal,\n\t\t\t\tcustomInstructions,\n\t\t\t);\n\n\t\t\t// Check if aborted after compact returned\n\t\t\tif (this.compactionAbortController.signal.aborted) {\n\t\t\t\tthrow new Error(\"Compaction cancelled\");\n\t\t\t}\n\n\t\t\t// Save compaction to session\n\t\t\tthis.sessionManager.saveCompaction(compactionEntry);\n\n\t\t\t// Reload session\n\t\t\tconst loaded = loadSessionFromEntries(this.sessionManager.loadEntries());\n\t\t\tthis.agent.replaceMessages(loaded.messages);\n\n\t\t\t// Rebuild UI\n\t\t\tthis.chatContainer.clear();\n\t\t\tthis.rebuildChatFromMessages();\n\n\t\t\t// Add compaction component at current position so user can see/expand the summary\n\t\t\tconst compactionComponent = new CompactionComponent(compactionEntry.tokensBefore, compactionEntry.summary);\n\t\t\tcompactionComponent.setExpanded(this.toolOutputExpanded);\n\t\t\tthis.chatContainer.addChild(compactionComponent);\n\n\t\t\t// Update footer with new state (fixes context % display)\n\t\t\tthis.footer.updateState(this.agent.state);\n\t\t} catch (error) {\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tif (message === \"Compaction cancelled\" || (error instanceof Error && error.name === \"AbortError\")) {\n\t\t\t\tthis.showError(\"Compaction cancelled\");\n\t\t\t} else {\n\t\t\t\tthis.showError(`Compaction failed: ${message}`);\n\t\t\t}\n\t\t} finally {\n\t\t\t// Clean up\n\t\t\tcompactingLoader.stop();\n\t\t\tthis.statusContainer.clear();\n\t\t\tthis.compactionAbortController = null;\n\t\t\tthis.editor.onEscape = originalOnEscape;\n\t\t}\n\n\t\t// Resubscribe to agent\n\t\tthis.subscribeToAgent();\n\t}\n\n\tprivate async handleCompactCommand(customInstructions?: string): Promise {\n\t\t// Check if there are any messages to compact\n\t\tconst entries = this.sessionManager.loadEntries();\n\t\tconst messageCount = entries.filter((e) => e.type === \"message\").length;\n\n\t\tif (messageCount < 2) {\n\t\t\tthis.showWarning(\"Nothing to compact (no messages yet)\");\n\t\t\treturn;\n\t\t}\n\n\t\tawait this.executeCompaction(customInstructions, false);\n\t}\n\n\tprivate handleAutocompactCommand(): void {\n\t\tconst currentEnabled = this.settingsManager.getCompactionEnabled();\n\t\tconst newState = !currentEnabled;\n\t\tthis.settingsManager.setCompactionEnabled(newState);\n\t\tthis.footer.setAutoCompactEnabled(newState);\n\n\t\t// Show brief notification (same style as thinking level toggle)\n\t\tthis.chatContainer.addChild(new Spacer(1));\n\t\tthis.chatContainer.addChild(new Text(theme.fg(\"dim\", `Auto-compaction: ${newState ? \"on\" : \"off\"}`), 1, 0));\n\t\tthis.ui.requestRender();\n\t}\n\n\tprivate updatePendingMessagesDisplay(): void {\n\t\tthis.pendingMessagesContainer.clear();\n\n\t\tif (this.queuedMessages.length > 0) {\n\t\t\tthis.pendingMessagesContainer.addChild(new Spacer(1));\n\n\t\t\tfor (const message of this.queuedMessages) {\n\t\t\t\tconst queuedText = theme.fg(\"dim\", \"Queued: \" + message);\n\t\t\t\tthis.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));\n\t\t\t}\n\t\t}\n\t}\n\n\tstop(): void {\n\t\tif (this.loadingAnimation) {\n\t\t\tthis.loadingAnimation.stop();\n\t\t\tthis.loadingAnimation = null;\n\t\t}\n\t\tthis.footer.dispose();\n\t\tif (this.isInitialized) {\n\t\t\tthis.ui.stop();\n\t\t\tthis.isInitialized = false;\n\t\t}\n\t}\n}\n"}],"isError":false,"timestamp":1765233673862}}
{"type":"message","timestamp":"2025-12-08T22:42:00.888Z","message":{"role":"assistant","content":[{"type":"text","text":"Now I have a complete picture. Let me do a deep analysis.\n\n## Deep Analysis: `main.ts` and `tui-renderer.ts`\n\n### Overview of the Problem\n\nThe code has evolved organically around three distinct run modes:\n1. **Interactive mode** (TUI) - full terminal UI experience\n2. **Print/JSON mode** (single-shot) - run once and exit, output text or JSON events\n3. **RPC mode** - headless, JSON-in/JSON-out, meant for embedding in other apps\n\nThe problem is that these modes share a lot of conceptual logic but it's implemented differently in each, leading to:\n\n### Code Duplication Issues\n\n#### 1. **Agent Event Handling**\n- `tui-renderer.ts` has `handleEvent()` that subscribes to agent events, saves messages via `sessionManager.saveMessage()`, and handles session initialization\n- `runSingleShotMode()` in `main.ts` has its own subscription loop for JSON output\n- `runRpcMode()` in `main.ts` has yet another subscription loop doing the same session saving plus JSON output\n\nAll three have nearly identical patterns:\n```typescript\nagent.subscribe((event) => {\n // output/render event\n if (event.type === \"message_end\") {\n sessionManager.saveMessage(event.message);\n // maybe init session\n }\n});\n```\n\n#### 2. **Auto-compaction Logic**\n- `tui-renderer.ts` has `checkAutoCompaction()` called after assistant messages\n- `runRpcMode()` has duplicate `checkAutoCompaction()` implementation\n- `runSingleShotMode()` doesn't do it (maybe intentional?)\n\n#### 3. **Bash Command Execution**\n- `tui-renderer.ts` has `executeBashCommand()` with streaming callbacks, temp file handling\n- `main.ts` has `executeRpcBashCommand()` which is nearly identical but without streaming\n\n#### 4. **Slash Command Expansion**\n- `runInteractiveMode()` calls `expandSlashCommand()` before `agent.prompt()`\n- `runSingleShotMode()` calls `expandSlashCommand()` before `agent.prompt()`\n- No slash command handling in RPC mode (intentional, but inconsistent)\n\n### Architecture Issues\n\n#### 1. **TuiRenderer is a God Class**\nAt ~2400 lines, `TuiRenderer` does too much:\n- TUI lifecycle management (init, render, event loop)\n- Agent event handling and session management\n- All slash command implementations (/export, /copy, /session, /changelog, /branch, /login, /logout, /queue, /theme, /clear, /compact, /autocompact, /debug, /resume)\n- Bash command execution (!command)\n- Model/thinking/theme selection UI\n- Message queuing display\n- Hotkey handling (Ctrl+C, Ctrl+P, Ctrl+O, Ctrl+T, Shift+Tab, Esc)\n\n#### 2. **Naming**\n- `TuiRenderer` doesn't just render, it's the entire interactive mode coordinator\n- Should be something like `InteractiveMode` or `TuiController`\n\n#### 3. **Mode-specific code scattered in main.ts**\n- `runInteractiveMode()`, `runSingleShotMode()`, `runRpcMode()` are all in `main.ts`\n- Model resolution, system prompt building, session loading also in `main.ts`\n\n---\n\n## Refactoring Plan\n\n### Phase 1: Extract Shared Logic into a Core Module\n\nCreate `packages/coding-agent/src/core/agent-session.ts`:\n\n```typescript\n/**\n * Handles agent lifecycle, session persistence, and event routing.\n * Shared between all modes.\n */\nexport class AgentSession {\n constructor(\n public agent: Agent,\n public sessionManager: SessionManager,\n public settingsManager: SettingsManager,\n ) {}\n\n /** Subscribe to agent events, handle session persistence, emit to callback */\n subscribe(onEvent: (event: AgentEvent) => void): () => void;\n\n /** Check and perform auto-compaction if enabled */\n checkAutoCompaction(): Promise;\n\n /** Execute manual compaction */\n compact(customInstructions?: string, signal?: AbortSignal): Promise;\n\n /** Reset agent and session (for /clear) */\n reset(): Promise;\n\n /** Branch session from entry index */\n branch(entryIndex: number): void;\n\n /** Switch to different session file */\n switchSession(sessionPath: string): Promise;\n}\n```\n\nThis consolidates:\n- Session saving on `message_end`\n- Session initialization check\n- Auto-compaction logic\n- Manual compaction (currently duplicated between `handleCompactCommand` and RPC)\n\n### Phase 2: Extract Bash Execution into Shared Module\n\nCreate `packages/coding-agent/src/core/bash-executor.ts`:\n\n```typescript\nexport interface BashExecutionResult {\n output: string;\n exitCode: number | null;\n cancelled: boolean;\n truncationResult?: TruncationResult;\n fullOutputPath?: string;\n}\n\n/**\n * Execute a bash command with optional streaming and cancellation.\n */\nexport function executeBash(\n command: string,\n options?: {\n onChunk?: (chunk: string) => void;\n signal?: AbortSignal;\n }\n): Promise;\n```\n\nThis replaces:\n- `TuiRenderer.executeBashCommand()`\n- `executeRpcBashCommand()` in `main.ts`\n\n### Phase 3: Create Mode-Specific Modules\n\nRename and split:\n\n```\nsrc/modes/\n interactive-mode.ts (renamed from tui-renderer.ts)\n print-mode.ts (extracted from runSingleShotMode)\n rpc-mode.ts (extracted from runRpcMode)\n index.ts (exports)\n```\n\nEach mode would:\n- Take an `AgentSession` instance\n- Implement mode-specific I/O\n- Use shared `executeBash()` if needed\n\n#### `print-mode.ts` (simple)\n```typescript\nexport async function runPrintMode(\n session: AgentSession,\n mode: \"text\" | \"json\",\n messages: string[],\n initialMessage?: string,\n initialAttachments?: Attachment[],\n): Promise;\n```\n\n#### `rpc-mode.ts`\n```typescript\nexport async function runRpcMode(session: AgentSession): Promise;\n```\n\n#### `interactive-mode.ts` (the big one)\n\nSplit `TuiRenderer` into:\n\n1. **InteractiveMode** - orchestrator class\n - Owns `TUI`, `AgentSession`\n - Main event loop\n - Hotkey routing\n\n2. **CommandHandler** - slash command implementations\n - Extract all `/` command handlers into a separate class\n - Each command is a method: `handleExport()`, `handleCopy()`, etc.\n\n3. **SelectorManager** - modal selector management\n - Handles showing/hiding model, thinking, theme, session, branch selectors\n - Currently this is a mess of `showXSelector()` / `hideXSelector()` methods\n\n### Phase 4: Simplify main.ts\n\nAfter extraction, `main.ts` becomes:\n\n```typescript\nexport async function main(args: string[]) {\n const parsed = parseArgs(args);\n \n if (parsed.help) { printHelp(); return; }\n if (parsed.export) { handleExport(parsed); return; }\n \n // Shared setup\n const { agent, sessionManager, settingsManager } = await setupAgent(parsed);\n const session = new AgentSession(agent, sessionManager, settingsManager);\n \n // Route to mode\n if (parsed.mode === \"rpc\") {\n await runRpcMode(session);\n } else if (parsed.print || parsed.mode) {\n await runPrintMode(session, parsed.mode || \"text\", parsed.messages, ...);\n } else {\n await runInteractiveMode(session, { version, scopedModels, fdPath, ... });\n }\n}\n```\n\n### Phase 5: Extract Setup Logic\n\nCreate `packages/coding-agent/src/core/setup.ts`:\n\n```typescript\nexport interface SetupResult {\n agent: Agent;\n sessionManager: SessionManager;\n settingsManager: SettingsManager;\n initialMessages: Message[];\n modelFallbackMessage: string | null;\n}\n\nexport async function setupAgent(options: SetupOptions): Promise;\n```\n\nThis extracts from `main.ts`:\n- Model resolution (CLI args → scope → session → settings → fallback)\n- System prompt building\n- Session loading (for --continue/--resume)\n- Thinking level resolution\n\n---\n\n## Summary of Changes\n\n| Current | Proposed |\n|---------|----------|\n| `main.ts` (1100+ lines) | `main.ts` (~200 lines) + `core/setup.ts` |\n| `tui/tui-renderer.ts` (2400 lines) | `modes/interactive-mode.ts` + `interactive/command-handler.ts` + `interactive/selector-manager.ts` |\n| `runSingleShotMode()` in main.ts | `modes/print-mode.ts` |\n| `runRpcMode()` in main.ts | `modes/rpc-mode.ts` |\n| Duplicated bash execution | `core/bash-executor.ts` |\n| Duplicated session/compaction logic | `core/agent-session.ts` |\n\n### Benefits\n\n1. **Clear separation of concerns**: Each mode has its own file\n2. **No more duplication**: Session management, bash execution, compaction in one place\n3. **Testability**: `AgentSession`, `CommandHandler` can be unit tested independently\n4. **Maintainability**: Adding a new mode means adding one file, not touching everything\n5. **Naming clarity**: `InteractiveMode` instead of `TuiRenderer`\n\n### Risks\n\n1. **Large refactor**: This is a significant change touching core code paths\n2. **TUI coupling**: Some slash commands deeply interact with TUI state (selectors, rendering). May need to keep some coupling.\n3. **Testing**: Need to manually test all three modes after refactoring\n\n---\n\nWhat's your take? Want me to proceed with any specific phase, or adjust the plan?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":1,"output":2526,"cacheRead":33637,"cacheWrite":7594,"totalTokens":43758,"cost":{"input":0.000005,"output":0.06315,"cacheRead":0.0168185,"cacheWrite":0.047462500000000005,"total":0.127436}},"stopReason":"stop","timestamp":1765233673863}}
@@ -11,34 +11,34 @@
{"type":"thinking_level_change","timestamp":"2025-12-08T22:45:43.324Z","thinkingLevel":"medium"}
{"type":"thinking_level_change","timestamp":"2025-12-08T22:45:43.658Z","thinkingLevel":"high"}
{"type":"message","timestamp":"2025-12-08T22:45:44.497Z","message":{"role":"user","content":[{"type":"text","text":"AgentSession looks like a good idea, can we put more stuff into that? what about thinking levels etc.? if we could get rid of the tui coupling then agent session could be resused for all modes, exposing all the things each mode can use"}],"timestamp":1765233944492}}
-{"type":"message","timestamp":"2025-12-08T22:47:00.748Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to expand AgentSession to be more comprehensive - a single abstraction that all three modes can use, containing all the shared agent-related logic. This would include:\n\n1. Agent lifecycle (prompt, abort, reset)\n2. Session persistence (save messages, model changes, thinking level changes)\n3. Model management (get/set model, cycle models, validate API keys)\n4. Thinking level management (get/set, cycle)\n5. Compaction (manual and auto)\n6. Bash execution (for ! commands)\n7. Slash command expansion\n8. Session branching/switching\n\nThe key insight is that if we can make AgentSession TUI-agnostic, it becomes the shared core for all modes. The modes would then just be thin layers:\n- Interactive mode: TUI + AgentSession\n- Print mode: stdout + AgentSession \n- RPC mode: JSON stdin/stdout + AgentSession\n\nLet me think about what should go into AgentSession and what the API would look like...\n\nThings currently scattered across main.ts and tui-renderer.ts that could be in AgentSession:\n\nFrom tui-renderer.ts:\n- subscribeToAgent() - agent event subscription + session saving\n- checkAutoCompaction()\n- cycleThinkingLevel() - but this has TUI feedback, need to split\n- cycleModel() - same, has TUI feedback\n- handleClearCommand() - reset logic\n- handleCompactCommand() - compaction logic\n- executeCompaction() - shared compaction execution\n- handleBashCommand() + executeBashCommand() - bash execution\n- handleResumeSession() - session switching\n- branch logic in showUserMessageSelector callback\n\nFrom main.ts:\n- runRpcMode's checkAutoCompaction\n- executeRpcBashCommand\n- Model resolution logic\n- Thinking level restoration from session\n\nThe key is to separate:\n1. **State changes** (AgentSession) - the \"what\"\n2. **UI feedback** (modes) - the \"how to show it\"\n\n\nI'll design an API that returns actionable results for different modes to handle. Each method will provide clear, structured information about the state change, allowing flexible UI representation across various interfaces.","thinkingSignature":"EpEQCkYIChgCKkCOOc7x0voaSJaoxFkK77OqTCsW0cEw33rGeBG5XYOOB8t7PJ3CAo1FgBV2I2Ssz2eO8p+3ftqOzwrRM5pqNwhLEgwYlTsShl6ptRc/gMsaDB//EGXyiedoE1La1CIw5blz9I/VNUdrvSNqd24jRrK/ctaEKHPJh5uwolrgI5TjViexXNiecUy78zcgZCVLKvgOo/Wle7bkzauPXDp9m131IlAH2IYtJMyJPCbzABgwd3cVMpzfwJA3bGoWALx0NEiCxNuHFOOfFJD+1DauFF2hDMnpJ3l88r1IbtR8/ti46AYESkwacl8cKbe4rTf/DYN32E+so4ShD7I3GvcWc2EIyVU9zjQvMtt/UHMYPdgPgEU6zxWzuWr4/pmZE6fnnhB8N99VQI6mMxmjpleccRt+zsalwU4XmWNJWuw5mxDoGv8CuYqVITJEc5caGadl5UHRSNwQFlE1l1XphD+H2B3PHEMs/SFzUo8Ppo3GSIN7vd8Ug9j0YnbMQ1sMQ6WGD6M+J6t1xM+lz+whJDqPH57++tHIqgZOTdpY7Df4Zf7NxEtpFAmw7hT+fpXHflUmYkxJE+C90Wm4phUZePtYJ/eqF8yKagkDwPledL2RPZAv2YYwEq2JEoa2s0XUrGQzm21g9W+Yf+KI5a7QUetlype3Zt4eKNmCatWf5WoAPaN8EXtMKgW9sLeKVr3ErvFVEN3BZyzrCgUpJ85pFY80Vw10FWBF7uABJgyFQ2SrFCmkWn4hucvv/9F0U3nIgvINZ5T8XripKtqKyfc5yrU1YWXuFzp19lTpP2W+wh3GNjAbF+o3d1TqAMpQMTvS/JzD+GDrDWwC8PrLNu5kt0LnLK4b20Qf1BgwXS6nnfMREt/iQ4JrWn2k0OKAi0IvhOaVqq++uVzYQAXaaA/hCq8hzRttqKjnc//ri//0nnpZkXgdl5vdpc9T5N9b1jXq7T+Sp0xpIeYuQF+Wj7HNV78N7IKoqb/0hrY90PqcZJUipFlbVVu8KF9QnOv6bFWRWbp5X+hTZaF7nd9cBr/LiiZYS2uxNshTkLkgiIjtcQbIcYGnciT4aKHpwkaxrYCjeBPYQouimvcDnOLJYRxAPcxlYRXswMz+8LPZAr2JiUb/0Pgrjfxmhduyn8Uf1Cey2aYpxtT/QIwqpgTzMWvMzBSdjjcvK8E4CTIqmX63dlzRJ5glkR20RXe25ZmwQpwnxGdhmrV7lz/Ngrkw4cdHfcnDK6fia2ILRm2Wl/nokH/ZdHBGxDrJk3joqyysf4iWs5vnjqKXmrqaA6QkMWkU9XGYsvtMx+cOzGgnb8tZqFTDfSkI7EHfoAlcfv3nRoyDei+ylpiY0G/q5SdzA49L0J6ut4+Wlon2mVUg/TUPaac13dniJE7jZtsCwGMJIBc7WqItv9YjRMcBCJwxMCxywz3/AAxYOBcskXmY4OGAY44Bq+RD2oX9EzzNVM13fzTtNRCq03fa6HqJc3rwt4hZYyaIHInN/CmW5ptG6GiwaHVfszGtpP0MsOLIWXnuT/yUk0FeSICklA4HNQ7+PdEYscPSFF/iWpZfjQYC0vVPhsGEJdPzrluQ9lJ0ZgDU2YfG5qamkOe6qjFpGlN5v+5h/B2ZLVN9MFRZOiIJwcg8VGcB+OH/KonGzCBTxwrBK8NTIW1eb96WDGqvoUWpz9H8LO5Zlz2VlUtWTbgxNu3GEe0F2I9V+DMQABPB35z+a91cBj2Nc7p/xcrvnTrcKcZU193mZA9dCVLZehlTegy3+QgmyerBbwwwZC+t7qPhy0eOtDUT/5KOE+Bn/w1CelwKgYaZc3shl7W0GpgDQQ2bqvXQOvgCbMwq7N9EXjKmYIlV6oRdfSgFzOXbL709FKFua6leoBJxsDmhXKqS6Oc78gNJTshuh+58angQMFrr9e5SWoE7ov3dALuR/+XiJK7Q8JMWJwkqc8GCGNQU9iknMZ6pmKT7CmZrtOvJWSHwSsrW+XRdD3SG6FCKQqrecCjbRoYNxnZ6N2ykuoEMkcNcchyelgTjsCurCCET6NS6gNjKrQN8Rj+LGW8xiOL1OPDc6/8urdrxcggTpnWLoLLS5fgXgaxspIQEyRBdeAGLg2QWaCFlRj++RImaIOeBbqeRUsix+wKt/lRYC+bfWKyTVs4mGRMupngNecz9r7utsLEXhjsCQsnruc2vRk8lRqzJddsSg5YhR/YZ3FhweFliEOIAyy+H8WfZNmJky6u6FRR4UBAjx/Sj5noEsJuzVXAuktbXDui+6fSOLLtyjqbSfO6z2cjd0eQQxhxfQwOUUFd1sGk38gUAphxyI4LmGi/0JAu4LB6gSjkW4/c6NQch/AAWGHCBmsavQJ6jGFigi0w0Nr2gAoEu/vhNXY/mMrzBQ/ViNWyIshZYWU5bRE5JuTIYjS7JH1HFrZBg7yTzxOkOq7hx7B7QPOzwDHCcgCVDU+DfxHT9N5c/rpzg2LLcjlPszAR+2BxNWlEzvf+KQLTHCqvVTTDIjAMDjg+Bx9YuFmt2zfJ671Uuh1Bw9PivlDyZ7ub1e+prCAe1/9K618fG7X0nVAsDb0THbeQ6PL0yULs4TepLLGwKyKAq03QtWLGh7onvokiqorzdULIyhQOhFf0Hci4JRnaFiLOc8AnVZZUGWxCkn1JOCdnbnUDKemYFqGtcoqokEFI0ZJy39gNGMmILuge+JJtzhVQLqQCHIZhXeRLRXp7xWZkhR7JSdF3JIso2VH9IgOahzT/SJBgB"},{"type":"text","text":"Good thinking. Let me expand AgentSession to be a comprehensive, TUI-agnostic abstraction that all modes can use.\n\n## Expanded AgentSession Design\n\n```typescript\n// src/core/agent-session.ts\n\nimport type { Agent, AgentEvent, AgentState, ThinkingLevel } from \"@mariozechner/pi-agent-core\";\nimport type { Model, Message } from \"@mariozechner/pi-ai\";\n\nexport interface AgentSessionConfig {\n agent: Agent;\n sessionManager: SessionManager;\n settingsManager: SettingsManager;\n scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;\n}\n\nexport interface BashResult {\n output: string;\n exitCode: number | null;\n cancelled: boolean;\n truncated: boolean;\n fullOutputPath?: string;\n}\n\nexport interface CompactionResult {\n tokensBefore: number;\n tokensAfter: number;\n summary: string;\n}\n\nexport interface ModelCycleResult {\n model: Model;\n thinkingLevel: ThinkingLevel;\n isScoped: boolean; // true if cycling within --models scope\n}\n\nexport interface PromptOptions {\n expandSlashCommands?: boolean; // default true\n attachments?: Attachment[];\n}\n\n/**\n * Core agent session management - shared between all modes.\n * Handles agent lifecycle, persistence, model/thinking management.\n * TUI-agnostic: returns data, doesn't render anything.\n */\nexport class AgentSession {\n readonly agent: Agent;\n readonly sessionManager: SessionManager;\n readonly settingsManager: SettingsManager;\n \n private scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;\n private fileCommands: FileSlashCommand[];\n private eventListeners: Array<(event: AgentEvent) => void> = [];\n private bashAbortController: AbortController | null = null;\n private compactionAbortController: AbortController | null = null;\n\n constructor(config: AgentSessionConfig);\n\n // ─────────────────────────────────────────────────────────────\n // State Access\n // ─────────────────────────────────────────────────────────────\n \n get state(): AgentState;\n get model(): Model | null;\n get thinkingLevel(): ThinkingLevel;\n get isStreaming(): boolean;\n get messages(): Message[];\n get sessionFile(): string;\n get sessionId(): string;\n\n // ─────────────────────────────────────────────────────────────\n // Event Subscription\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Subscribe to agent events. Handles session persistence internally.\n * Returns unsubscribe function.\n */\n subscribe(listener: (event: AgentEvent) => void): () => void;\n\n // ─────────────────────────────────────────────────────────────\n // Prompting\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Send a prompt to the agent. Expands slash commands by default.\n * Validates model and API key before sending.\n * Throws if no model or API key.\n */\n prompt(text: string, options?: PromptOptions): Promise;\n\n /**\n * Queue a message (when agent is streaming).\n */\n queueMessage(text: string): Promise;\n\n /**\n * Clear queued messages, return them for restoration.\n */\n clearQueue(): string[];\n\n /**\n * Abort current operation and wait for idle.\n */\n abort(): Promise;\n\n /**\n * Reset agent and session (start fresh).\n */\n reset(): Promise;\n\n // ─────────────────────────────────────────────────────────────\n // Model Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Set model directly. Validates API key.\n * Saves to session and settings.\n * Throws if no API key available.\n */\n setModel(model: Model): Promise;\n\n /**\n * Cycle to next model (uses scoped models if available).\n * Returns the new model info, or null if only one model available.\n */\n cycleModel(): Promise;\n\n /**\n * Get all available models (with valid API keys).\n */\n getAvailableModels(): Promise[]>;\n\n // ─────────────────────────────────────────────────────────────\n // Thinking Level Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Set thinking level. Silently uses \"off\" if model doesn't support it.\n * Saves to session and settings.\n */\n setThinkingLevel(level: ThinkingLevel): void;\n\n /**\n * Cycle to next thinking level.\n * Returns new level, or null if model doesn't support thinking.\n */\n cycleThinkingLevel(): ThinkingLevel | null;\n\n /**\n * Check if current model supports thinking.\n */\n supportsThinking(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Compaction\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Manually compact the session context.\n * Aborts current agent operation first.\n */\n compact(customInstructions?: string): Promise;\n\n /**\n * Cancel in-progress compaction.\n */\n abortCompaction(): void;\n\n /**\n * Check if auto-compaction should run, and run it if so.\n * Called internally after assistant messages.\n * Returns result if compaction occurred, null otherwise.\n */\n checkAutoCompaction(): Promise;\n\n /**\n * Toggle auto-compaction setting.\n */\n setAutoCompactionEnabled(enabled: boolean): void;\n \n get autoCompactionEnabled(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Bash Execution (for ! commands)\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Execute a bash command. Adds result to agent context.\n * @param onChunk - Optional streaming callback for output\n */\n executeBash(command: string, onChunk?: (chunk: string) => void): Promise;\n\n /**\n * Cancel running bash command.\n */\n abortBash(): void;\n\n /**\n * Check if bash command is running.\n */\n get isBashRunning(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Session Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Switch to a different session file.\n * Aborts current operation, loads messages, restores model/thinking.\n */\n switchSession(sessionPath: string): Promise;\n\n /**\n * Create a branch from a specific entry index.\n * Returns the text of the selected user message (for editor pre-fill).\n */\n branch(entryIndex: number): string;\n\n /**\n * Get all user messages from session (for branch selector).\n * Returns entries with index and text.\n */\n getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }>;\n\n /**\n * Get session stats for /session command.\n */\n getSessionStats(): SessionStats;\n\n /**\n * Export session to HTML.\n */\n exportToHtml(outputPath?: string): string;\n\n // ─────────────────────────────────────────────────────────────\n // Clipboard\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Get last assistant message text for /copy.\n * Returns null if no assistant message exists.\n */\n getLastAssistantText(): string | null;\n}\n\nexport interface SessionStats {\n sessionFile: string;\n sessionId: string;\n userMessages: number;\n assistantMessages: number;\n toolCalls: number;\n toolResults: number;\n totalMessages: number;\n tokens: {\n input: number;\n output: number;\n cacheRead: number;\n cacheWrite: number;\n total: number;\n };\n cost: number;\n}\n```\n\n## How Modes Would Use It\n\n### Print Mode (simplest)\n\n```typescript\n// src/modes/print-mode.ts\n\nexport async function runPrintMode(\n session: AgentSession,\n mode: \"text\" | \"json\",\n messages: string[],\n initialMessage?: string,\n initialAttachments?: Attachment[],\n): Promise {\n \n if (mode === \"json\") {\n // Just pipe events to stdout\n session.subscribe((event) => {\n console.log(JSON.stringify(event));\n });\n }\n\n // Send messages\n if (initialMessage) {\n await session.prompt(initialMessage, { attachments: initialAttachments });\n }\n for (const message of messages) {\n await session.prompt(message);\n }\n\n // In text mode, output final response\n if (mode === \"text\") {\n const lastText = session.getLastAssistantText();\n if (lastText) {\n console.log(lastText);\n }\n }\n}\n```\n\n### RPC Mode\n\n```typescript\n// src/modes/rpc-mode.ts\n\nexport async function runRpcMode(session: AgentSession): Promise {\n // Pipe all events to stdout as JSON\n session.subscribe((event) => {\n console.log(JSON.stringify(event));\n });\n\n // Listen for JSON input\n const rl = readline.createInterface({ input: process.stdin });\n \n rl.on(\"line\", async (line: string) => {\n try {\n const input = JSON.parse(line);\n \n switch (input.type) {\n case \"prompt\":\n await session.prompt(input.message, { attachments: input.attachments });\n break;\n \n case \"abort\":\n await session.abort();\n break;\n \n case \"compact\":\n const result = await session.compact(input.customInstructions);\n console.log(JSON.stringify({ type: \"compaction\", ...result }));\n break;\n \n case \"bash\":\n const bashResult = await session.executeBash(input.command);\n console.log(JSON.stringify({ type: \"bash_end\", ...bashResult }));\n break;\n \n case \"setModel\":\n // Could add model switching via RPC\n break;\n \n case \"setThinking\":\n session.setThinkingLevel(input.level);\n break;\n }\n } catch (error: any) {\n console.log(JSON.stringify({ type: \"error\", error: error.message }));\n }\n });\n\n return new Promise(() => {}); // Never resolves\n}\n```\n\n### Interactive Mode (most complex, but cleaner)\n\n```typescript\n// src/modes/interactive-mode.ts\n\nexport class InteractiveMode {\n private session: AgentSession;\n private tui: TUI;\n private components: TuiComponents; // Chat container, editor, footer, etc.\n \n constructor(session: AgentSession, options: InteractiveModeOptions) {\n this.session = session;\n this.tui = new TUI(new ProcessTerminal());\n this.components = this.createComponents();\n \n // Subscribe to session events for rendering\n session.subscribe((event) => this.handleEvent(event));\n }\n\n async run(): Promise {\n await this.init();\n \n while (true) {\n const input = await this.getEditorInput();\n await this.handleInput(input);\n }\n }\n\n private async handleInput(text: string): Promise {\n // Slash commands\n if (text === \"/thinking\") {\n this.showThinkingSelector();\n return;\n }\n if (text === \"/model\") {\n this.showModelSelector();\n return;\n }\n if (text === \"/compact\") {\n await this.handleCompact();\n return;\n }\n if (text === \"/copy\") {\n this.handleCopy();\n return;\n }\n // ... other commands\n \n // Bash command\n if (text.startsWith(\"!\")) {\n await this.handleBash(text.slice(1));\n return;\n }\n \n // Regular prompt\n try {\n await this.session.prompt(text);\n } catch (error) {\n this.showError(error.message);\n }\n }\n\n // ─── Slash Command Handlers ───\n \n private async handleCompact(): Promise {\n this.showLoader(\"Compacting...\");\n try {\n const result = await this.session.compact();\n this.rebuildChat();\n this.addCompactionComponent(result);\n } catch (error) {\n this.showError(`Compaction failed: ${error.message}`);\n } finally {\n this.hideLoader();\n }\n }\n\n private handleCopy(): void {\n const text = this.session.getLastAssistantText();\n if (!text) {\n this.showError(\"No assistant message to copy\");\n return;\n }\n copyToClipboard(text);\n this.showMessage(\"Copied to clipboard\");\n }\n\n private async handleBash(command: string): Promise {\n const component = new BashExecutionComponent(command);\n this.components.chat.addChild(component);\n \n const result = await this.session.executeBash(command, (chunk) => {\n component.appendOutput(chunk);\n this.tui.requestRender();\n });\n \n component.setComplete(result);\n this.tui.requestRender();\n }\n\n // ─── Hotkey Handlers ───\n \n private handleEscape(): void {\n if (this.session.isStreaming) {\n // Restore queued messages to editor\n const queued = this.session.clearQueue();\n const current = this.components.editor.getText();\n this.components.editor.setText([...queued, current].filter(Boolean).join(\"\\n\\n\"));\n this.session.abort();\n } else if (this.session.isBashRunning) {\n this.session.abortBash();\n }\n }\n\n private handleShiftTab(): void {\n const newLevel = this.session.cycleThinkingLevel();\n if (newLevel === null) {\n this.showMessage(\"Model doesn't support thinking\");\n } else {\n this.showMessage(`Thinking: ${newLevel}`);\n this.updateEditorBorder();\n }\n }\n\n private async handleCtrlP(): Promise {\n const result = await this.session.cycleModel();\n if (result === null) {\n this.showMessage(\"Only one model available\");\n } else {\n const thinkingStr = result.thinkingLevel !== \"off\" ? ` (thinking: ${result.thinkingLevel})` : \"\";\n this.showMessage(`Switched to ${result.model.name || result.model.id}${thinkingStr}`);\n this.updateEditorBorder();\n }\n }\n\n // ─── Event Handling (just rendering) ───\n \n private handleEvent(event: AgentEvent): void {\n // Pure rendering logic - no session management\n switch (event.type) {\n case \"agent_start\":\n this.showLoader(\"Working...\");\n break;\n case \"message_start\":\n if (event.message.role === \"user\") {\n this.addUserMessage(event.message);\n } else {\n this.startStreamingAssistant();\n }\n break;\n case \"message_update\":\n this.updateStreamingAssistant(event.message);\n break;\n case \"message_end\":\n this.finalizeMessage(event.message);\n break;\n case \"agent_end\":\n this.hideLoader();\n break;\n }\n this.tui.requestRender();\n }\n}\n```\n\n## What This Achieves\n\n### 1. **Single Source of Truth**\nAll agent/session logic lives in `AgentSession`:\n- No more duplicate `checkAutoCompaction()` \n- No more duplicate bash execution\n- No more duplicate session saving\n- Model/thinking changes always go through one path\n\n### 2. **Clear Separation**\n- `AgentSession` = state management, persistence, business logic\n- Modes = I/O and presentation\n\n### 3. **Testable**\n```typescript\n// Can unit test AgentSession without any TUI\nconst session = new AgentSession({ agent, sessionManager, settingsManager });\nawait session.setThinkingLevel(\"high\");\nexpect(session.thinkingLevel).toBe(\"high\");\nexpect(settingsManager.getDefaultThinkingLevel()).toBe(\"high\");\n```\n\n### 4. **Extensible**\nAdding a new mode (e.g., LSP server mode, web socket mode) just means:\n1. Create `AgentSession`\n2. Subscribe to events\n3. Implement your I/O layer\n\n### 5. **Reduced main.ts**\nModel resolution, thinking restoration, session loading can all be helper functions or part of an `AgentSession.create()` factory:\n\n```typescript\n// main.ts\nconst session = await AgentSession.create({\n args: parsed,\n settingsManager,\n sessionManager,\n});\n\nif (mode === \"rpc\") await runRpcMode(session);\nelse if (mode === \"print\") await runPrintMode(session, ...);\nelse await runInteractiveMode(session, ...);\n```\n\n---\n\n## File Structure After Refactor\n\n```\nsrc/\n core/\n agent-session.ts # The big shared abstraction\n bash-executor.ts # Low-level bash execution (used by AgentSession)\n setup.ts # Factory/initialization helpers\n modes/\n interactive/\n interactive-mode.ts # Main class\n components.ts # TUI component wiring\n selectors.ts # Model/thinking/theme selector management\n print-mode.ts\n rpc-mode.ts\n main.ts # Just arg parsing and mode routing (~200 lines)\n```\n\nDoes this expanded design address your concerns? Should I start implementing `AgentSession`?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-5","usage":{"input":10,"output":5195,"cacheRead":0,"cacheWrite":43835,"totalTokens":49040,"cost":{"input":0.00005,"output":0.12987500000000002,"cacheRead":0,"cacheWrite":0.27396875000000004,"total":0.40389375000000005}},"stopReason":"stop","timestamp":1765233944493}}
+{"type":"message","timestamp":"2025-12-08T22:47:00.748Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user wants to expand AgentSession to be more comprehensive - a single abstraction that all three modes can use, containing all the shared agent-related logic. This would include:\n\n1. Agent lifecycle (prompt, abort, reset)\n2. Session persistence (save messages, model changes, thinking level changes)\n3. Model management (get/set model, cycle models, validate API keys)\n4. Thinking level management (get/set, cycle)\n5. Compaction (manual and auto)\n6. Bash execution (for ! commands)\n7. Slash command expansion\n8. Session branching/switching\n\nThe key insight is that if we can make AgentSession TUI-agnostic, it becomes the shared core for all modes. The modes would then just be thin layers:\n- Interactive mode: TUI + AgentSession\n- Print mode: stdout + AgentSession \n- RPC mode: JSON stdin/stdout + AgentSession\n\nLet me think about what should go into AgentSession and what the API would look like...\n\nThings currently scattered across main.ts and tui-renderer.ts that could be in AgentSession:\n\nFrom tui-renderer.ts:\n- subscribeToAgent() - agent event subscription + session saving\n- checkAutoCompaction()\n- cycleThinkingLevel() - but this has TUI feedback, need to split\n- cycleModel() - same, has TUI feedback\n- handleClearCommand() - reset logic\n- handleCompactCommand() - compaction logic\n- executeCompaction() - shared compaction execution\n- handleBashCommand() + executeBashCommand() - bash execution\n- handleResumeSession() - session switching\n- branch logic in showUserMessageSelector callback\n\nFrom main.ts:\n- runRpcMode's checkAutoCompaction\n- executeRpcBashCommand\n- Model resolution logic\n- Thinking level restoration from session\n\nThe key is to separate:\n1. **State changes** (AgentSession) - the \"what\"\n2. **UI feedback** (modes) - the \"how to show it\"\n\n\nI'll design an API that returns actionable results for different modes to handle. Each method will provide clear, structured information about the state change, allowing flexible UI representation across various interfaces.","thinkingSignature":"EpEQCkYIChgCKkCOOc7x0voaSJaoxFkK77OqTCsW0cEw33rGeBG5XYOOB8t7PJ3CAo1FgBV2I2Ssz2eO8p+3ftqOzwrRM5pqNwhLEgwYlTsShl6ptRc/gMsaDB//EGXyiedoE1La1CIw5blz9I/VNUdrvSNqd24jRrK/ctaEKHPJh5uwolrgI5TjViexXNiecUy78zcgZCVLKvgOo/Wle7bkzauPXDp9m131IlAH2IYtJMyJPCbzABgwd3cVMpzfwJA3bGoWALx0NEiCxNuHFOOfFJD+1DauFF2hDMnpJ3l88r1IbtR8/ti46AYESkwacl8cKbe4rTf/DYN32E+so4ShD7I3GvcWc2EIyVU9zjQvMtt/UHMYPdgPgEU6zxWzuWr4/pmZE6fnnhB8N99VQI6mMxmjpleccRt+zsalwU4XmWNJWuw5mxDoGv8CuYqVITJEc5caGadl5UHRSNwQFlE1l1XphD+H2B3PHEMs/SFzUo8Ppo3GSIN7vd8Ug9j0YnbMQ1sMQ6WGD6M+J6t1xM+lz+whJDqPH57++tHIqgZOTdpY7Df4Zf7NxEtpFAmw7hT+fpXHflUmYkxJE+C90Wm4phUZePtYJ/eqF8yKagkDwPledL2RPZAv2YYwEq2JEoa2s0XUrGQzm21g9W+Yf+KI5a7QUetlype3Zt4eKNmCatWf5WoAPaN8EXtMKgW9sLeKVr3ErvFVEN3BZyzrCgUpJ85pFY80Vw10FWBF7uABJgyFQ2SrFCmkWn4hucvv/9F0U3nIgvINZ5T8XripKtqKyfc5yrU1YWXuFzp19lTpP2W+wh3GNjAbF+o3d1TqAMpQMTvS/JzD+GDrDWwC8PrLNu5kt0LnLK4b20Qf1BgwXS6nnfMREt/iQ4JrWn2k0OKAi0IvhOaVqq++uVzYQAXaaA/hCq8hzRttqKjnc//ri//0nnpZkXgdl5vdpc9T5N9b1jXq7T+Sp0xpIeYuQF+Wj7HNV78N7IKoqb/0hrY90PqcZJUipFlbVVu8KF9QnOv6bFWRWbp5X+hTZaF7nd9cBr/LiiZYS2uxNshTkLkgiIjtcQbIcYGnciT4aKHpwkaxrYCjeBPYQouimvcDnOLJYRxAPcxlYRXswMz+8LPZAr2JiUb/0Pgrjfxmhduyn8Uf1Cey2aYpxtT/QIwqpgTzMWvMzBSdjjcvK8E4CTIqmX63dlzRJ5glkR20RXe25ZmwQpwnxGdhmrV7lz/Ngrkw4cdHfcnDK6fia2ILRm2Wl/nokH/ZdHBGxDrJk3joqyysf4iWs5vnjqKXmrqaA6QkMWkU9XGYsvtMx+cOzGgnb8tZqFTDfSkI7EHfoAlcfv3nRoyDei+ylpiY0G/q5SdzA49L0J6ut4+Wlon2mVUg/TUPaac13dniJE7jZtsCwGMJIBc7WqItv9YjRMcBCJwxMCxywz3/AAxYOBcskXmY4OGAY44Bq+RD2oX9EzzNVM13fzTtNRCq03fa6HqJc3rwt4hZYyaIHInN/CmW5ptG6GiwaHVfszGtpP0MsOLIWXnuT/yUk0FeSICklA4HNQ7+PdEYscPSFF/iWpZfjQYC0vVPhsGEJdPzrluQ9lJ0ZgDU2YfG5qamkOe6qjFpGlN5v+5h/B2ZLVN9MFRZOiIJwcg8VGcB+OH/KonGzCBTxwrBK8NTIW1eb96WDGqvoUWpz9H8LO5Zlz2VlUtWTbgxNu3GEe0F2I9V+DMQABPB35z+a91cBj2Nc7p/xcrvnTrcKcZU193mZA9dCVLZehlTegy3+QgmyerBbwwwZC+t7qPhy0eOtDUT/5KOE+Bn/w1CelwKgYaZc3shl7W0GpgDQQ2bqvXQOvgCbMwq7N9EXjKmYIlV6oRdfSgFzOXbL709FKFua6leoBJxsDmhXKqS6Oc78gNJTshuh+58angQMFrr9e5SWoE7ov3dALuR/+XiJK7Q8JMWJwkqc8GCGNQU9iknMZ6pmKT7CmZrtOvJWSHwSsrW+XRdD3SG6FCKQqrecCjbRoYNxnZ6N2ykuoEMkcNcchyelgTjsCurCCET6NS6gNjKrQN8Rj+LGW8xiOL1OPDc6/8urdrxcggTpnWLoLLS5fgXgaxspIQEyRBdeAGLg2QWaCFlRj++RImaIOeBbqeRUsix+wKt/lRYC+bfWKyTVs4mGRMupngNecz9r7utsLEXhjsCQsnruc2vRk8lRqzJddsSg5YhR/YZ3FhweFliEOIAyy+H8WfZNmJky6u6FRR4UBAjx/Sj5noEsJuzVXAuktbXDui+6fSOLLtyjqbSfO6z2cjd0eQQxhxfQwOUUFd1sGk38gUAphxyI4LmGi/0JAu4LB6gSjkW4/c6NQch/AAWGHCBmsavQJ6jGFigi0w0Nr2gAoEu/vhNXY/mMrzBQ/ViNWyIshZYWU5bRE5JuTIYjS7JH1HFrZBg7yTzxOkOq7hx7B7QPOzwDHCcgCVDU+DfxHT9N5c/rpzg2LLcjlPszAR+2BxNWlEzvf+KQLTHCqvVTTDIjAMDjg+Bx9YuFmt2zfJ671Uuh1Bw9PivlDyZ7ub1e+prCAe1/9K618fG7X0nVAsDb0THbeQ6PL0yULs4TepLLGwKyKAq03QtWLGh7onvokiqorzdULIyhQOhFf0Hci4JRnaFiLOc8AnVZZUGWxCkn1JOCdnbnUDKemYFqGtcoqokEFI0ZJy39gNGMmILuge+JJtzhVQLqQCHIZhXeRLRXp7xWZkhR7JSdF3JIso2VH9IgOahzT/SJBgB"},{"type":"text","text":"Good thinking. Let me expand AgentSession to be a comprehensive, TUI-agnostic abstraction that all modes can use.\n\n## Expanded AgentSession Design\n\n```typescript\n// src/core/agent-session.ts\n\nimport type { Agent, AgentEvent, AgentState, ThinkingLevel } from \"@oh-my-pi/pi-agent-core\";\nimport type { Model, Message } from \"@oh-my-pi/pi-ai\";\n\nexport interface AgentSessionConfig {\n agent: Agent;\n sessionManager: SessionManager;\n settingsManager: SettingsManager;\n scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;\n}\n\nexport interface BashResult {\n output: string;\n exitCode: number | null;\n cancelled: boolean;\n truncated: boolean;\n fullOutputPath?: string;\n}\n\nexport interface CompactionResult {\n tokensBefore: number;\n tokensAfter: number;\n summary: string;\n}\n\nexport interface ModelCycleResult {\n model: Model;\n thinkingLevel: ThinkingLevel;\n isScoped: boolean; // true if cycling within --models scope\n}\n\nexport interface PromptOptions {\n expandSlashCommands?: boolean; // default true\n attachments?: Attachment[];\n}\n\n/**\n * Core agent session management - shared between all modes.\n * Handles agent lifecycle, persistence, model/thinking management.\n * TUI-agnostic: returns data, doesn't render anything.\n */\nexport class AgentSession {\n readonly agent: Agent;\n readonly sessionManager: SessionManager;\n readonly settingsManager: SettingsManager;\n \n private scopedModels: Array<{ model: Model; thinkingLevel: ThinkingLevel }>;\n private fileCommands: FileSlashCommand[];\n private eventListeners: Array<(event: AgentEvent) => void> = [];\n private bashAbortController: AbortController | null = null;\n private compactionAbortController: AbortController | null = null;\n\n constructor(config: AgentSessionConfig);\n\n // ─────────────────────────────────────────────────────────────\n // State Access\n // ─────────────────────────────────────────────────────────────\n \n get state(): AgentState;\n get model(): Model | null;\n get thinkingLevel(): ThinkingLevel;\n get isStreaming(): boolean;\n get messages(): Message[];\n get sessionFile(): string;\n get sessionId(): string;\n\n // ─────────────────────────────────────────────────────────────\n // Event Subscription\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Subscribe to agent events. Handles session persistence internally.\n * Returns unsubscribe function.\n */\n subscribe(listener: (event: AgentEvent) => void): () => void;\n\n // ─────────────────────────────────────────────────────────────\n // Prompting\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Send a prompt to the agent. Expands slash commands by default.\n * Validates model and API key before sending.\n * Throws if no model or API key.\n */\n prompt(text: string, options?: PromptOptions): Promise;\n\n /**\n * Queue a message (when agent is streaming).\n */\n queueMessage(text: string): Promise;\n\n /**\n * Clear queued messages, return them for restoration.\n */\n clearQueue(): string[];\n\n /**\n * Abort current operation and wait for idle.\n */\n abort(): Promise;\n\n /**\n * Reset agent and session (start fresh).\n */\n reset(): Promise;\n\n // ─────────────────────────────────────────────────────────────\n // Model Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Set model directly. Validates API key.\n * Saves to session and settings.\n * Throws if no API key available.\n */\n setModel(model: Model): Promise;\n\n /**\n * Cycle to next model (uses scoped models if available).\n * Returns the new model info, or null if only one model available.\n */\n cycleModel(): Promise;\n\n /**\n * Get all available models (with valid API keys).\n */\n getAvailableModels(): Promise[]>;\n\n // ─────────────────────────────────────────────────────────────\n // Thinking Level Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Set thinking level. Silently uses \"off\" if model doesn't support it.\n * Saves to session and settings.\n */\n setThinkingLevel(level: ThinkingLevel): void;\n\n /**\n * Cycle to next thinking level.\n * Returns new level, or null if model doesn't support thinking.\n */\n cycleThinkingLevel(): ThinkingLevel | null;\n\n /**\n * Check if current model supports thinking.\n */\n supportsThinking(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Compaction\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Manually compact the session context.\n * Aborts current agent operation first.\n */\n compact(customInstructions?: string): Promise;\n\n /**\n * Cancel in-progress compaction.\n */\n abortCompaction(): void;\n\n /**\n * Check if auto-compaction should run, and run it if so.\n * Called internally after assistant messages.\n * Returns result if compaction occurred, null otherwise.\n */\n checkAutoCompaction(): Promise;\n\n /**\n * Toggle auto-compaction setting.\n */\n setAutoCompactionEnabled(enabled: boolean): void;\n \n get autoCompactionEnabled(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Bash Execution (for ! commands)\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Execute a bash command. Adds result to agent context.\n * @param onChunk - Optional streaming callback for output\n */\n executeBash(command: string, onChunk?: (chunk: string) => void): Promise;\n\n /**\n * Cancel running bash command.\n */\n abortBash(): void;\n\n /**\n * Check if bash command is running.\n */\n get isBashRunning(): boolean;\n\n // ─────────────────────────────────────────────────────────────\n // Session Management\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Switch to a different session file.\n * Aborts current operation, loads messages, restores model/thinking.\n */\n switchSession(sessionPath: string): Promise;\n\n /**\n * Create a branch from a specific entry index.\n * Returns the text of the selected user message (for editor pre-fill).\n */\n branch(entryIndex: number): string;\n\n /**\n * Get all user messages from session (for branch selector).\n * Returns entries with index and text.\n */\n getUserMessagesForBranching(): Array<{ entryIndex: number; text: string }>;\n\n /**\n * Get session stats for /session command.\n */\n getSessionStats(): SessionStats;\n\n /**\n * Export session to HTML.\n */\n exportToHtml(outputPath?: string): string;\n\n // ─────────────────────────────────────────────────────────────\n // Clipboard\n // ─────────────────────────────────────────────────────────────\n \n /**\n * Get last assistant message text for /copy.\n * Returns null if no assistant message exists.\n */\n getLastAssistantText(): string | null;\n}\n\nexport interface SessionStats {\n sessionFile: string;\n sessionId: string;\n userMessages: number;\n assistantMessages: number;\n toolCalls: number;\n toolResults: number;\n totalMessages: number;\n tokens: {\n input: number;\n output: number;\n cacheRead: number;\n cacheWrite: number;\n total: number;\n };\n cost: number;\n}\n```\n\n## How Modes Would Use It\n\n### Print Mode (simplest)\n\n```typescript\n// src/modes/print-mode.ts\n\nexport async function runPrintMode(\n session: AgentSession,\n mode: \"text\" | \"json\",\n messages: string[],\n initialMessage?: string,\n initialAttachments?: Attachment[],\n): Promise {\n \n if (mode === \"json\") {\n // Just pipe events to stdout\n session.subscribe((event) => {\n console.log(JSON.stringify(event));\n });\n }\n\n // Send messages\n if (initialMessage) {\n await session.prompt(initialMessage, { attachments: initialAttachments });\n }\n for (const message of messages) {\n await session.prompt(message);\n }\n\n // In text mode, output final response\n if (mode === \"text\") {\n const lastText = session.getLastAssistantText();\n if (lastText) {\n console.log(lastText);\n }\n }\n}\n```\n\n### RPC Mode\n\n```typescript\n// src/modes/rpc-mode.ts\n\nexport async function runRpcMode(session: AgentSession): Promise {\n // Pipe all events to stdout as JSON\n session.subscribe((event) => {\n console.log(JSON.stringify(event));\n });\n\n // Listen for JSON input\n const rl = readline.createInterface({ input: process.stdin });\n \n rl.on(\"line\", async (line: string) => {\n try {\n const input = JSON.parse(line);\n \n switch (input.type) {\n case \"prompt\":\n await session.prompt(input.message, { attachments: input.attachments });\n break;\n \n case \"abort\":\n await session.abort();\n break;\n \n case \"compact\":\n const result = await session.compact(input.customInstructions);\n console.log(JSON.stringify({ type: \"compaction\", ...result }));\n break;\n \n case \"bash\":\n const bashResult = await session.executeBash(input.command);\n console.log(JSON.stringify({ type: \"bash_end\", ...bashResult }));\n break;\n \n case \"setModel\":\n // Could add model switching via RPC\n break;\n \n case \"setThinking\":\n session.setThinkingLevel(input.level);\n break;\n }\n } catch (error: any) {\n console.log(JSON.stringify({ type: \"error\", error: error.message }));\n }\n });\n\n return new Promise(() => {}); // Never resolves\n}\n```\n\n### Interactive Mode (most complex, but cleaner)\n\n```typescript\n// src/modes/interactive-mode.ts\n\nexport class InteractiveMode {\n private session: AgentSession;\n private tui: TUI;\n private components: TuiComponents; // Chat container, editor, footer, etc.\n \n constructor(session: AgentSession, options: InteractiveModeOptions) {\n this.session = session;\n this.tui = new TUI(new ProcessTerminal());\n this.components = this.createComponents();\n \n // Subscribe to session events for rendering\n session.subscribe((event) => this.handleEvent(event));\n }\n\n async run(): Promise {\n await this.init();\n \n while (true) {\n const input = await this.getEditorInput();\n await this.handleInput(input);\n }\n }\n\n private async handleInput(text: string): Promise {\n // Slash commands\n if (text === \"/thinking\") {\n this.showThinkingSelector();\n return;\n }\n if (text === \"/model\") {\n this.showModelSelector();\n return;\n }\n if (text === \"/compact\") {\n await this.handleCompact();\n return;\n }\n if (text === \"/copy\") {\n this.handleCopy();\n return;\n }\n // ... other commands\n \n // Bash command\n if (text.startsWith(\"!\")) {\n await this.handleBash(text.slice(1));\n return;\n }\n \n // Regular prompt\n try {\n await this.session.prompt(text);\n } catch (error) {\n this.showError(error.message);\n }\n }\n\n // ─── Slash Command Handlers ───\n \n private async handleCompact(): Promise {\n this.showLoader(\"Compacting...\");\n try {\n const result = await this.session.compact();\n this.rebuildChat();\n this.addCompactionComponent(result);\n } catch (error) {\n this.showError(`Compaction failed: ${error.message}`);\n } finally {\n this.hideLoader();\n }\n }\n\n private handleCopy(): void {\n const text = this.session.getLastAssistantText();\n if (!text) {\n this.showError(\"No assistant message to copy\");\n return;\n }\n copyToClipboard(text);\n this.showMessage(\"Copied to clipboard\");\n }\n\n private async handleBash(command: string): Promise {\n const component = new BashExecutionComponent(command);\n this.components.chat.addChild(component);\n \n const result = await this.session.executeBash(command, (chunk) => {\n component.appendOutput(chunk);\n this.tui.requestRender();\n });\n \n component.setComplete(result);\n this.tui.requestRender();\n }\n\n // ─── Hotkey Handlers ───\n \n private handleEscape(): void {\n if (this.session.isStreaming) {\n // Restore queued messages to editor\n const queued = this.session.clearQueue();\n const current = this.components.editor.getText();\n this.components.editor.setText([...queued, current].filter(Boolean).join(\"\\n\\n\"));\n this.session.abort();\n } else if (this.session.isBashRunning) {\n this.session.abortBash();\n }\n }\n\n private handleShiftTab(): void {\n const newLevel = this.session.cycleThinkingLevel();\n if (newLevel === null) {\n this.showMessage(\"Model doesn't support thinking\");\n } else {\n this.showMessage(`Thinking: ${newLevel}`);\n this.updateEditorBorder();\n }\n }\n\n private async handleCtrlP(): Promise