Add pi-tool-display (compact tool rendering, adaptive diffs) + pi-lsp-extension (LSP diagnostics/hover/definitions) — vendored from npm 0.5.0 / 1.3.0

This commit is contained in:
2026-08-05 07:07:51 +10:00
parent b13022a6fd
commit 41cc33d141
92 changed files with 27025 additions and 0 deletions

View File

@@ -0,0 +1 @@
* @samfoy

View File

@@ -0,0 +1,22 @@
name: Publish
on:
release:
types: [published]
jobs:
publish:
runs-on: ubuntu-latest
environment: npm
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm publish --provenance --access public

View File

@@ -0,0 +1,57 @@
{
"id": "1773681807334-feature",
"presetName": "feature",
"prompt": "implement specs/tree-sitter-integration.md",
"startTime": 1773681807334,
"endTime": 1773683050481,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "\n\nPlanning is complete. Here's the summary:\n\nThe spec calls for a tree-sitter layer that provides zero-config code intelligence as a fallback when no LSP server is running. The plan covers:\n\n**New files (5):**\n- `src/tree-sitter/parser-manager.ts` — WASM parser loading/caching per language\n- `src/tree-sitter/symbol-extractor.ts` — AST walking to extract symbols, find definitions, get node info at positions\n- `src/tree-sitter/workspace-index.ts` — project-wide symbol index (capped at 5k files, incremental updates)\n- `src/resolve-provider.ts` — routing helper: LSP → tree-sitter → \"no intelligence available\"\n- `src/tools/code-overview.ts` — new tool for project structure summaries\n\n**Modified files (6):**\n- `src/tools/symbols.ts`, `definition.ts`, `hover.ts`, `diagnostics.ts` — each gets a tree-sitter fallback path\n- `src/index.ts` — wire up TreeSitterManager, WorkspaceIndex, register code_overview\n- `src/file-sync.ts` — invalidate tree-sitter cache on file changes\n\n**Dependencies:** `web-tree-sitter` + `tree-sitter-wasms` (pre-built grammar WASMs, loaded lazily per language)\n\n**Key design decisions:**\n- Grammars come from `tree-sitter-wasms` npm package (lazy-loaded, no download script)\n- LSP always takes priority; tree-sitter is fallback only\n- Workspace index caps at 5000 files initially, indexes remaining on demand\n- Same output formats as existing LSP tools so the LLM sees consistent results\n\nThe scratchpad has the full step-by-step breakdown. Ready for the Builder hat.",
"timestamp": 1773681927017
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "Implementation complete. Here's what I built:\n\n5 new files and 6 modified files implementing the tree-sitter integration spec. The core layer (`parser-manager.ts`, `symbol-extractor.ts`, `workspace-index.ts`) provides WASM-based parsing with per-language symbol extraction for TS/JS, Python, Rust, Go, Java, C/C++, and Ruby. The `resolve-provider.ts` routes each tool call to LSP (if running) or tree-sitter (fallback). All four existing tools (`symbols`, `definition`, `hover`, `diagnostics`) now gracefully degrade to tree-sitter when no LSP server is available. A new `code_overview` tool summarizes project structure using tree-sitter symbol extraction.\n\nKey discovery during implementation: `web-tree-sitter@0.26.x` has ABI incompatibility with `tree-sitter-wasms` pre-built grammars — pinned to `0.24.7` which works correctly. All grammars load and parse successfully, and the project compiles cleanly with zero TypeScript errors.",
"timestamp": 1773682806836
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "## Review Summary: ✅ Pass (2 fixes applied)\n\n### Bugs Fixed\n\n1. **`export_statement` dead code** (high severity) — `TS_JS_SYMBOLS` included an `export_statement` mapping that shadowed the intended unwrapping logic in `extractFromNode`. All exported TS/JS declarations were either incorrectly named or silently dropped. **Fixed** by removing the conflicting mapping entry. Verified all export patterns now extract correctly.\n\n2. **Unused `prefix` parameter** (low) — `WorkspaceIndex.addSymbols()` had a dead `prefix?` parameter. Removed.\n\n### Spec Compliance: Complete\nAll requirements met — zero-config fallback for 19 languages, graceful LSP→tree-sitter routing, `code_overview` tool, workspace indexing with 5000-file cap, FileSync integration, `[tree-sitter]` annotations, and syntax-only diagnostic disclaimer.\n\n### Compilation: Clean\n`tsc --noEmit` passes. WASM ABI compatibility verified at runtime.",
"timestamp": 1773683001658
}
]
}

View File

@@ -0,0 +1,57 @@
{
"id": "1773683776080-feature",
"presetName": "feature",
"prompt": "specs/completions-tool.md",
"startTime": 1773683776080,
"endTime": 1773684508903,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "Everything is properly wired up.\n\n---\n\n## ✅ Summary\n\n### What was done\n\nImplemented the `lsp_completions` tool per the spec in `specs/completions-tool.md`:\n\n**New file: `src/tools/completions.ts`** (~210 lines)\n- Takes `path`, `line`, `character`, and optional `limit` (default 20) parameters\n- Sends `textDocument/completion` to the LSP server\n- Normalizes response (handles both `CompletionList` and `CompletionItem[]`)\n- Sorts by LSP `sortText` ranking, takes top N items\n- Resolves items in parallel via `completionItem/resolve` (with 2s per-item timeout) for full documentation and signatures\n- Formats output as compact text: kind (padded), label+detail, doc summary (first 2 lines, 120 char cap)\n- Includes `renderCall`/`renderResult` for TUI display\n- Handles: no LSP server, server doesn't support completions, empty results, request failures\n\n**Modified: `src/index.ts`**\n- Added import and registration of `createCompletionsTool`\n- Updated header comment\n\n### Design decisions (from spec open questions)\n- **Skipped synthetic trigger** for v1 — inserting/reverting `.` through didChange risks race conditions with diagnostics. LLM can type `obj.` directly.\n- **Resolve all top N items** in parallel with 2s timeout per item — uses `Promise.allSettled` so failures fall back gracefully to unresolved items.\n- **No filter parameter** for v1 — LSP sort order + limit handles prioritization.\n- **No tree-sitter fallback** — completions fundamentally require type info (per spec non-goals).",
"timestamp": 1773683999660
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "## Summary\n\nImplemented the **synthetic trigger** feature for `lsp_completions` — the main missing piece from the spec. The tool already had solid core functionality (completions, resolve, formatting). Here's what I added:\n\n### Changes to `src/tools/completions.ts`:\n\n1. **`trigger` parameter** — `\"auto\"` (default) or `\"none\"`. When auto, the tool detects if the cursor is at the end of an identifier and temporarily inserts a `.` to trigger member completions without requiring the LLM to edit the file.\n\n2. **`shouldSyntheticTrigger()` helper** — checks if position is after an identifier char (`\\w`, `)`, `]`, `>`) and there's no dot already present.\n\n3. **`insertDot()` helper** — string manipulation to insert `.` at a position.\n\n4. **Modified execute flow** — insert dot → `didChange` to LSP → request completions → **always revert** in `finally` block → format results. Falls through gracefully on any failure.\n\n5. **Updated descriptions and render** — mentions synthetic trigger in tool description, promptSnippet, and output header.\n\nTypeScript compiles cleanly with `tsc --noEmit`. No changes needed to `index.ts` — the tool was already registered.",
"timestamp": 1773684274098
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "## 🔎 Review Complete\n\n### Spec Compliance: ✅ All requirements met\n\nThe implementation covers every feature in the spec: tool parameters, synthetic trigger, kind labels, doc summaries, sort/limit, parallel resolve with timeout, registration, and rendering.\n\n### Bug Found: Version Desync 🐛\n\n**The synthetic trigger sends `didChange` with hardcoded versions 99990/99991 directly to the LSP client, bypassing FileSync's version tracker.** After the revert, FileSync still thinks the document is at a low version (e.g. 3), but the LSP server expects > 99991. The next real edit will send version 4, which monotonic-version-enforcing LSP servers will reject — causing stale diagnostics and completions.\n\n**Recommended fix:** Add a `withTemporaryContent(uri, content, fn)` method to LspManager that coordinates version tracking with FileSync, keeping the completions tool clean and making temporary edits reusable.\n\n### Verdict: **→ Back to Builder** to fix the version desync before proceeding to commit.",
"timestamp": 1773684389440
}
]
}

View File

@@ -0,0 +1,57 @@
{
"id": "1773685339450-feature",
"presetName": "feature",
"prompt": "specs/structural-search-rewrite.md",
"startTime": 1773685339450,
"endTime": 1773686879376,
"outcome": "Task complete ✓",
"iterations": 4,
"history": [
{
"hat": "planner",
"event": "plan.start",
"iteration": 1
},
{
"hat": "builder",
"event": "tasks.ready",
"iteration": 2
},
{
"hat": "reviewer",
"event": "build.done",
"iteration": 3
},
{
"hat": "committer",
"event": "review.approved",
"iteration": 4
}
],
"iterationLogs": [
{
"iteration": 1,
"hatKey": "planner",
"hatName": "📋 Planner",
"event": "tasks.ready",
"summary": "---\n\n## Summary\n\n### 📋 Plan → 🔨 Build → 🔍 Review — Complete\n\nImplemented the **Structural Code Search & Rewrite** feature per `specs/structural-search-rewrite.md`.\n\n### New files (5):\n\n| File | Lines | Purpose |\n|------|-------|---------|\n| `src/tree-sitter/pattern-compiler.ts` | ~200 | Parses metavariable patterns (`$NAME`, `$$$NAME`) into matchable `PatternNode` trees. Handles wrapper contexts for expression/statement fragments. |\n| `src/tree-sitter/search-engine.ts` | ~290 | AST matching engine — walks target trees depth-first, matches against pattern with field-name-aware child matching. Collects files by language, caps at 5000 files. |\n| `src/tree-sitter/rewrite-engine.ts` | ~100 | Substitutes captured metavariable values into replacement templates. Applies changes bottom-up to preserve byte offsets. |\n| `src/tools/code-search.ts` | ~100 | `code_search` tool — pattern, language, path?, max_results?. Returns matches with file:line, matched text, and metavariable bindings. |\n| `src/tools/code-rewrite.ts` | ~140 | `code_rewrite` tool — pattern, replacement, language, path?, dry_run?(default true). Previews or applies structural transformations. |\n\n### Modified files (1):\n\n| File | Change |\n|------|--------|\n| `src/index.ts` | Added imports and `pi.registerTool()` calls for both new tools |\n\n### Key design decisions:\n\n1. **Custom AST matcher instead of tree-sitter queries** — tree-sitter's S-expression queries match by node type, not text content. A custom matcher lets us match literal text for concrete nodes and wildcard for metavariables, which is what users expect.\n\n2. **Field-name-aware child matching** — pattern children with field names (e.g., `function`, `arguments`, `name`) are matched against target children with the same field name. Extra target children (like TypeScript type annotations) are allowed. This prevents false negatives when the target language has more syntax than the pattern mentions.\n\n3. **Wrapper-based pattern parsing** — patterns that a",
"timestamp": 1773686181539
},
{
"iteration": 2,
"hatKey": "builder",
"hatName": "⚙️ Builder",
"event": "build.done",
"summary": "## Summary\n\nThe structural search & rewrite feature is fully implemented and tested. I found and fixed two bugs in the existing code:\n\n### Bug 1: Semicolon stripping in rewrites\n**`src/tree-sitter/rewrite-engine.ts`** — Added `preserveTrailingSemicolon()` so that `var x = 10;` → `const x = 10;` (not `const x = 10`). The matched AST node includes the `;` as part of the statement, and the rewrite engine now preserves it when the replacement template doesn't explicitly include one.\n\n### Bug 2: Pattern compilation fails for Python\n**`src/tree-sitter/pattern-compiler.ts`** — `$ARG` is not a valid Python identifier. Added **placeholder preprocessing**: before parsing, metavars are replaced with language-safe placeholders (`$NAME` → `__META_NAME__`, `$$$NAME` → `__VMETA_NAME__`), then decoded back after the AST is built. Also added Python-specific wrapper strategies (Python uses `module` as root, not `program`).\n\n### Test results\n- **`test-structural-search.ts`**: 52/52 passing ✅\n- **`test-tree-sitter.ts`**: 62/62 passing ✅ (no regressions)\n- **`tsc --noEmit`**: clean compile ✅",
"timestamp": 1773686551683
},
{
"iteration": 3,
"hatKey": "reviewer",
"hatName": "🔎 Reviewer",
"event": "review.approved",
"summary": "**Review complete — PASS ✅**. The implementation is solid and ready for the commit hat. Four minor non-blocking issues noted in the scratchpad for future improvement.",
"timestamp": 1773686779441
}
]
}

View File

@@ -0,0 +1,26 @@
# Ralph Scratchpad
Preset: feature
Task: specs/structural-search-rewrite.md
---
## Committer — Iteration 4
### Committed: `6fe71f1`
```
feat: add structural code search and rewrite tools
```
7 files changed, 1414 insertions:
- `src/tree-sitter/pattern-compiler.ts` — metavar placeholder encoding, language wrappers
- `src/tree-sitter/search-engine.ts` — recursive AST matcher, file collection
- `src/tree-sitter/rewrite-engine.ts` — capture substitution, bottom-up application
- `src/tools/code-search.ts` — search tool wrapper
- `src/tools/code-rewrite.ts` — rewrite tool wrapper with dry-run
- `src/index.ts` — tool registration
- `test-structural-search.ts` — 52 tests
### Status: ✅ DONE
All spec requirements met. 52 tests passing, tsc clean. Feature complete.

View File

@@ -0,0 +1,192 @@
# pi-lsp-extension
A [pi](https://github.com/mariozechner/pi-mono) coding agent extension that integrates Language Server Protocol (LSP) servers, giving the LLM access to the same language intelligence that powers your IDE.
## Tools
| Tool | Description |
|------|-------------|
| `lsp_diagnostics` | Compilation errors and warnings for a file |
| `lsp_hover` | Type information and documentation at a position |
| `lsp_definition` | Go to definition of a symbol |
| `lsp_references` | Find all references to a symbol |
| `lsp_symbols` | List file symbols or search workspace symbols |
| `lsp_rename` | Preview rename refactoring (returns planned edits) |
| `lsp_completions` | Code completion suggestions at a position |
| `code_overview` | Project structure, key files, and symbols (tree-sitter) |
| `code_search` | Find code by AST structure with metavariables |
| `code_rewrite` | Transform code matching structural patterns |
LSP servers start lazily — they only spin up when a tool is first used on a file of that language. For slow servers (e.g. jdtls), you can [auto-start them on session launch](#project-config).
## Auto-diagnostics
After a successful `write` or `edit`, if an LSP server is already running for that file type, the extension automatically appends compilation errors to the tool result. This gives the LLM immediate feedback without requiring a separate `lsp_diagnostics` call.
- Scoped to the single changed file (no workspace-wide noise)
- Only errors, max 10 lines — keeps context lean
- Only fires when a server is already running (no lazy startup)
## Installation
```bash
git clone https://github.com/samfoy/pi-lsp-extension.git
cd pi-lsp-extension
npm install
```
Add to your pi `settings.json`:
```json
{
"extensions": ["/path/to/pi-lsp-extension/src/index.ts"]
}
```
Or run directly:
```bash
pi -e /path/to/pi-lsp-extension/src/index.ts
```
## Supported Languages
Install the language server you need, then it works automatically:
| Language | Server | Install |
|----------|--------|---------|
| TypeScript/JavaScript | `typescript-language-server` | `npm i -g typescript-language-server typescript` |
| Python | `pyright-langserver` | `pip install pyright` |
| Rust | `rust-analyzer` | [rustup](https://rustup.rs/) |
| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` |
| Java | `jdtls` | [Eclipse JDT.LS](https://github.com/eclipse-jdtls/eclipse.jdt.ls) |
Add more at runtime:
```
/lsp-config ruby solargraph stdio
/lsp-config lua lua-language-server
```
## Commands
| Command | Description |
|---------|-------------|
| `/lsp` | Show status of running LSP servers |
| `/lsp-restart <lang>` | Restart an LSP server (kills daemon, re-initializes) |
| `/lsp-config <lang> <cmd> [args]` | Configure a language server |
| `/lsp-lombok [path]` | Set Lombok jar path for Java (or show current) |
| `/bemol [run\|watch\|stop\|status]` | Manage bemol (Brazil workspaces) |
## How it Works
1. **Lazy startup** — servers start on first tool use for a file type (or eagerly via [`.pi-lsp.json`](#project-config))
2. **Tree-sitter fallback** — when no LSP server is running, tools like `lsp_diagnostics`, `lsp_hover`, `lsp_definition`, and `lsp_symbols` fall back to tree-sitter for syntax errors, signatures, and symbol extraction
3. **File sync** — pi's `read`/`write`/`edit` operations are automatically synced to the LSP via `didOpen`/`didChange`, with LRU eviction (`didClose`) after 100 tracked files
4. **Diagnostics cache** — the server pushes diagnostics asynchronously; tools read from a local cache
5. **Auto-diagnostics** — errors are appended to write/edit results when a server is running
6. **Shared daemons** — in supported workspaces, LSP servers run as background daemons shared across pi sessions
## Lombok Support (Java)
If your Java project uses [Lombok](https://projectlombok.org/), jdtls needs the Lombok agent jar to understand generated code. The extension resolves the jar in this order:
1. **`/lsp-lombok` command** — set the path at runtime:
```
/lsp-lombok /path/to/lombok.jar
```
2. **`LOMBOK_JAR` environment variable** — set before starting pi:
```bash
export LOMBOK_JAR=/path/to/lombok.jar
pi
```
3. **Auto-detection** — in Brazil workspaces, the extension searches `env/Lombok-*/runtime/lib/` and `env/gradle-cache-2/` automatically.
Run `/lsp-lombok` with no arguments to see which jar is currently configured.
## Project Config
Create a `.pi-lsp.json` file in your project root to configure LSP behavior per-project:
```json
{
"autoStart": ["java", "typescript"],
"lombokJar": "auto",
"autoInjectDiagnostics": ["typescript"],
"servers": {
"python": { "command": "pylsp", "args": [] }
}
}
```
| Field | Description |
|-------|-------------|
| `autoStart` | Array of language IDs to start eagerly on session launch. Servers begin initializing in the background immediately — no need to wait for the first tool call. Ideal for slow servers like `jdtls`. |
| `lombokJar` | Path to a Lombok jar (absolute or relative to project root), or `"auto"` to auto-detect in Brazil workspaces. Applied before auto-start so jdtls launches with the correct `-javaagent` flag. |
| `autoInjectDiagnostics` | Controls whether LSP errors are auto-appended to `write`/`edit` tool results. `true` (default) enables for all languages, `false` disables entirely, or pass an array of language IDs (e.g. `["typescript"]`) to enable selectively. Disable for Java/Brazil workspaces where Lombok and dependency-chain false positives create noise. |
| `servers` | Custom server configs keyed by language ID. Overrides the built-in defaults. Each entry has `command`, optional `args` (string array), and optional `env` (key-value pairs). |
The config file is loaded once at session start. Changes require restarting the pi session.
**Example for a Java Brazil workspace:**
```json
{
"autoStart": ["java"],
"lombokJar": "auto"
}
```
This triggers bemol + jdtls startup as soon as the session begins, so by the time you need `lsp_diagnostics` or `lsp_hover`, the server is already warm.
## Architecture
```
src/
├── index.ts # Extension entry point, .pi-lsp.json config loader
├── lsp-client.ts # JSON-RPC client (stdio + socket modes)
├── lsp-manager.ts # Server lifecycle, per-language instances
├── file-sync.ts # didOpen/didChange tracking
├── lsp-daemon.ts # Background daemon for shared servers
├── lsp-daemon-launcher.cjs
├── bemol.ts # Brazil workspace support
├── locks.ts # File-based locking for daemon coordination
├── resolve-provider.ts # LSP vs tree-sitter provider selection
├── shared/
│ ├── constants.ts # Skip dirs, file size limits
│ ├── debug.ts # Debug logger (PI_LSP_DEBUG=1)
│ ├── format.ts # Location formatting utilities
│ ├── language-map.ts # File extension → language ID mapping
│ └── timing.ts # Timing constants
├── tree-sitter/
│ ├── parser-manager.ts # WASM parser loading and caching
│ ├── pattern-compiler.ts # Metavariable pattern → AST matcher
│ ├── search-engine.ts # Structural search over files
│ ├── rewrite-engine.ts # Structural find-and-replace
│ ├── symbol-extractor.ts # Per-language symbol extraction
│ └── workspace-index.ts # Project-wide symbol index
└── tools/
├── diagnostics.ts
├── hover.ts
├── definition.ts
├── references.ts
├── symbols.ts
├── rename.ts
├── completions.ts
├── code-overview.ts
├── code-search.ts
└── code-rewrite.ts
```
## Tips
- Position parameters are 1-indexed (line 1, column 1 = first character)
- `lsp_rename` returns a preview — the LLM uses `edit`/`write` to apply changes
- Use `/lsp-restart java` after changing Lombok config — jdtls needs a full restart to pick up `-javaagent` changes
- Set `PI_LSP_DEBUG=1` to enable debug logging for troubleshooting
- The extension adds a system prompt guideline nudging the LLM to check diagnostics after edits
## License
MIT

View File

@@ -0,0 +1,303 @@
/**
* Bemol — Amazon's Brazil workspace → LSP bridge integration.
*
* Detects Brazil workspaces, runs bemol to generate LSP configs,
* manages a background bemol --watch process, and reads workspace
* root folders for multi-root LSP support.
*/
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { existsSync, readFileSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { acquireLock, releaseLock, isLockedByOther } from "./locks.js";
export interface BemolStatus {
isBrazilWorkspace: boolean;
workspaceRoot: string | null;
bemolAvailable: boolean;
hasConfig: boolean;
watching: boolean;
workspaceRoots: string[];
}
export interface BemolRunResult {
success: boolean;
output: string;
duration: number;
}
export class BemolManager {
private watchProcess: ChildProcess | null = null;
private _workspaceRoot: string | null = null;
private _bemolAvailable: boolean | null = null;
private _bemolRan = false;
constructor(rootDir: string) {
this._workspaceRoot = BemolManager.findWorkspaceRoot(rootDir);
}
/** Whether the cwd is inside a Brazil workspace */
get isBrazilWorkspace(): boolean {
return this._workspaceRoot !== null;
}
/** The Brazil workspace root (directory containing packageInfo) */
get workspaceRoot(): string | null {
return this._workspaceRoot;
}
/** Whether bemol has already been run this session */
get bemolRan(): boolean {
return this._bemolRan;
}
/** Whether bemol --watch is running */
get isWatching(): boolean {
return this.watchProcess !== null && !this.watchProcess.killed;
}
/**
* Walk up from startDir looking for a `packageInfo` file.
* Returns the directory containing it, or null.
*/
static findWorkspaceRoot(startDir: string): string | null {
let dir = startDir;
// Walk up at most 20 levels to avoid infinite loops
for (let i = 0; i < 20; i++) {
const candidate = join(dir, "packageInfo");
try {
if (existsSync(candidate) && statSync(candidate).isFile()) {
return dir;
}
} catch {
// ignore
}
const parent = dirname(dir);
if (parent === dir) break; // reached filesystem root
dir = parent;
}
return null;
}
/** Check if bemol is available on PATH */
static isBemolAvailable(): boolean {
try {
execSync("which bemol", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
/** Check if bemol is available (cached) */
get bemolAvailable(): boolean {
if (this._bemolAvailable === null) {
this._bemolAvailable = BemolManager.isBemolAvailable();
}
return this._bemolAvailable;
}
/** Check if .bemol/ws_root_folders exists */
hasConfig(): boolean {
if (!this._workspaceRoot) return false;
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
return existsSync(foldersFile);
}
/**
* Read .bemol/ws_root_folders and return array of existing directory paths.
*/
getWorkspaceRoots(): string[] {
if (!this._workspaceRoot) return [];
const foldersFile = join(this._workspaceRoot, ".bemol", "ws_root_folders");
try {
const content = readFileSync(foldersFile, "utf-8");
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.filter((dir) => {
try {
return existsSync(dir) && statSync(dir).isDirectory();
} catch {
return false;
}
});
} catch {
return [];
}
}
/**
* Get workspace folders formatted for LSP InitializeParams.
* Returns array of { uri, name } objects.
*/
getWorkspaceFolders(): { uri: string; name: string }[] {
const roots = this.getWorkspaceRoots();
if (roots.length === 0) return [];
return roots.map((dir) => ({
uri: pathToFileURL(dir).toString(),
name: dir.split("/").pop() ?? "package",
}));
}
/**
* Run `bemol --verbose` in the workspace root.
* Returns result with success status, output, and duration.
*/
async runBemol(): Promise<BemolRunResult> {
if (!this._workspaceRoot) {
return { success: false, output: "Not in a Brazil workspace", duration: 0 };
}
if (!this.bemolAvailable) {
return { success: false, output: "bemol is not installed (try: toolbox install bemol)", duration: 0 };
}
const start = Date.now();
return new Promise((resolve) => {
const child = spawn("bemol", ["--verbose"], {
cwd: this._workspaceRoot!,
stdio: ["ignore", "pipe", "pipe"],
});
const chunks: Buffer[] = [];
child.stdout?.on("data", (data) => chunks.push(data));
child.stderr?.on("data", (data) => chunks.push(data));
// Timeout after 120s
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve({
success: false,
output: "bemol timed out after 120 seconds",
duration: Date.now() - start,
});
}, 120_000);
child.on("error", (err) => {
clearTimeout(timer);
resolve({
success: false,
output: `bemol failed to start: ${err.message}`,
duration: Date.now() - start,
});
});
child.on("close", (code) => {
clearTimeout(timer);
const output = Buffer.concat(chunks).toString();
this._bemolRan = true;
resolve({
success: code === 0,
output: output || (code === 0 ? "bemol completed successfully" : `bemol exited with code ${code}`),
duration: Date.now() - start,
});
});
});
}
/**
* Ensure bemol has been run at least once this session.
* If configs exist already, skips. If another session is running bemol,
* waits briefly then checks again. If not, runs bemol.
* Returns true if configs are available after this call.
*/
async ensureBemolConfig(sessionId?: string): Promise<boolean> {
if (!this.isBrazilWorkspace) return false;
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
if (this._bemolRan) return this.hasConfig();
if (!this.bemolAvailable) return false;
// Check if another session is already running bemol
if (this._workspaceRoot && isLockedByOther(this._workspaceRoot, "bemol")) {
// Wait up to 60s for the other session to finish
for (let i = 0; i < 12; i++) {
await new Promise((r) => setTimeout(r, 5000));
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
if (!isLockedByOther(this._workspaceRoot!, "bemol")) break;
}
// If config appeared, use it. Otherwise fall through to run our own.
if (this.hasConfig()) {
this._bemolRan = true;
return true;
}
}
// Acquire bemol lock
const sid = sessionId ?? `${process.pid}-${Date.now()}`;
if (this._workspaceRoot) {
acquireLock(this._workspaceRoot, "bemol", sid);
}
try {
const result = await this.runBemol();
return result.success && this.hasConfig();
} finally {
if (this._workspaceRoot) {
releaseLock(this._workspaceRoot, "bemol");
}
}
}
/**
* Start `bemol --watch` as a background process.
*/
startWatch(): boolean {
if (!this._workspaceRoot || !this.bemolAvailable) return false;
if (this.isWatching) return true; // already running
this.watchProcess = spawn("bemol", ["--watch"], {
cwd: this._workspaceRoot,
stdio: ["ignore", "ignore", "ignore"],
detached: false,
});
this.watchProcess.on("error", () => {
this.watchProcess = null;
});
this.watchProcess.on("exit", () => {
this.watchProcess = null;
});
return true;
}
/** Stop the background bemol --watch process */
stopWatch(): void {
if (this.watchProcess) {
this.watchProcess.kill("SIGTERM");
setTimeout(() => {
if (this.watchProcess && !this.watchProcess.killed) {
this.watchProcess.kill("SIGKILL");
}
}, 2000);
this.watchProcess = null;
}
}
/** Get full status summary */
getStatus(): BemolStatus {
return {
isBrazilWorkspace: this.isBrazilWorkspace,
workspaceRoot: this._workspaceRoot,
bemolAvailable: this.bemolAvailable,
hasConfig: this.hasConfig(),
watching: this.isWatching,
workspaceRoots: this.getWorkspaceRoots(),
};
}
/** Shutdown: stop watch, clean up */
shutdown(): void {
this.stopWatch();
}
}

View File

@@ -0,0 +1,195 @@
/**
* pi-lsp-bemol — Bemol workspace provider for pi-lsp-extension.
*
* Detects Amazon Brazil workspaces, runs bemol to generate LSP configs,
* and registers a WorkspaceProvider with the LSP extension via pi.events.
*
* Install: place in ~/.pi/agent/extensions/pi-lsp-bemol/ or load with pi -e
*
* Requires: bemol on PATH (toolbox install bemol)
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { join } from "node:path";
import { BemolManager } from "./bemol.js";
/**
* WorkspaceProvider interface — must match the one in pi-lsp-extension.
* The LSP extension accepts any object with this shape via pi.events.
*/
interface WorkspaceProvider {
readonly type: string;
readonly workspaceRoot: string | null;
readonly stateDir: string | null;
getWorkspaceFolders(): { uri: string; name: string }[];
ensureReady(sessionId?: string): Promise<boolean>;
getStatusText(): string;
shutdown(): void;
}
class BemolWorkspaceProvider implements WorkspaceProvider {
readonly type = "bemol";
readonly manager: BemolManager;
constructor(manager: BemolManager) {
this.manager = manager;
}
get workspaceRoot(): string | null {
return this.manager.workspaceRoot;
}
get stateDir(): string | null {
const root = this.manager.workspaceRoot;
return root ? join(root, ".bemol") : null;
}
getWorkspaceFolders(): { uri: string; name: string }[] {
return this.manager.getWorkspaceFolders();
}
async ensureReady(sessionId?: string): Promise<boolean> {
return this.manager.ensureBemolConfig(sessionId);
}
getStatusText(): string {
const hasConfig = this.manager.hasConfig();
const hasBemol = this.manager.bemolAvailable;
if (hasConfig) return "Brazil workspace (bemol config found)";
if (hasBemol) return "Brazil workspace (bemol will run on first LSP use)";
return "Brazil workspace (bemol not installed)";
}
shutdown(): void {
this.manager.shutdown();
}
}
export default function bemolExtension(pi: ExtensionAPI) {
// Detect and register at factory time (synchronous, before any session_start).
// This ensures the provider is available before autoStart kicks in.
const bemol = new BemolManager(process.cwd());
let provider: BemolWorkspaceProvider | null = null;
if (bemol.isBrazilWorkspace) {
provider = new BemolWorkspaceProvider(bemol);
// Store on event bus so the LSP extension can find it regardless of load order
(pi.events as any)["lsp:workspace-provider"] = provider;
pi.events.emit("lsp:register-workspace-provider", provider);
}
// Register /bemol command and update status once we have UI context
pi.on("session_start", async (_event, ctx) => {
if (!provider) return;
// Re-detect with ctx.cwd in case it differs from process.cwd()
if (ctx.cwd !== process.cwd()) {
const freshBemol = new BemolManager(ctx.cwd);
if (freshBemol.isBrazilWorkspace) {
provider = new BemolWorkspaceProvider(freshBemol);
pi.events.emit("lsp:register-workspace-provider", provider);
} else {
provider = null;
return;
}
}
pi.registerCommand("bemol", {
description: "Manage bemol: /bemol [run|watch|stop|status]",
handler: async (args, cmdCtx) => {
if (!provider) return;
const mgr = provider.manager;
const subcommand = args?.trim().toLowerCase() || "run";
switch (subcommand) {
case "run": {
if (!mgr.bemolAvailable) {
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
return;
}
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("warning", "LSP: running bemol..."));
cmdCtx.ui.notify("Running bemol --verbose...", "info");
const result = await mgr.runBemol();
if (result.success) {
const roots = mgr.getWorkspaceRoots();
cmdCtx.ui.notify(
`bemol completed in ${(result.duration / 1000).toFixed(1)}s\n${roots.length} package root(s) configured`,
"info"
);
} else {
cmdCtx.ui.notify(`bemol failed:\n${result.output.slice(0, 500)}`, "error");
}
cmdCtx.ui.setStatus("lsp", cmdCtx.ui.theme.fg("accent", "LSP: Brazil workspace (bemol done)"));
break;
}
case "watch": {
if (!mgr.bemolAvailable) {
cmdCtx.ui.notify("bemol is not installed. Run: toolbox install bemol", "warning");
return;
}
if (mgr.isWatching) {
cmdCtx.ui.notify("bemol --watch is already running", "info");
return;
}
const started = mgr.startWatch();
if (started) {
cmdCtx.ui.notify("Started bemol --watch in background", "info");
cmdCtx.ui.setStatus("bemol", cmdCtx.ui.theme.fg("accent", "bemol: watching"));
} else {
cmdCtx.ui.notify("Failed to start bemol --watch", "error");
}
break;
}
case "stop": {
if (!mgr.isWatching) {
cmdCtx.ui.notify("bemol --watch is not running", "info");
return;
}
mgr.stopWatch();
cmdCtx.ui.notify("Stopped bemol --watch", "info");
cmdCtx.ui.setStatus("bemol", "");
break;
}
case "status": {
const status = mgr.getStatus();
const lines = [
`Brazil workspace: ${status.isBrazilWorkspace ? "yes" : "no"}`,
`Workspace root: ${status.workspaceRoot ?? "N/A"}`,
`bemol available: ${status.bemolAvailable ? "yes" : "no"}`,
`bemol config: ${status.hasConfig ? "yes" : "missing"}`,
`bemol watch: ${status.watching ? "running" : "stopped"}`,
`Package roots: ${status.workspaceRoots.length}`,
];
if (status.workspaceRoots.length > 0) {
const shown = status.workspaceRoots.slice(0, 10);
for (const root of shown) {
lines.push(` ${root}`);
}
if (status.workspaceRoots.length > 10) {
lines.push(` ... and ${status.workspaceRoots.length - 10} more`);
}
}
cmdCtx.ui.notify(lines.join("\n"), "info");
break;
}
default:
cmdCtx.ui.notify(
"Usage: /bemol [run|watch|stop|status]\n run — run bemol --verbose\n watch — start bemol --watch\n stop — stop bemol --watch\n status — show bemol status",
"info"
);
}
},
});
});
pi.on("session_shutdown", async () => {
if (provider) {
provider.shutdown();
provider = null;
}
});
}

View File

@@ -0,0 +1,144 @@
/**
* Workspace Lock — cross-session coordination for bemol runs.
*
* Uses PID-based lockfiles in `.bemol/locks/` to prevent multiple pi sessions
* from running bemol simultaneously in the same workspace.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, openSync, closeSync, statSync, constants as fsConstants } from "node:fs";
import { join } from "node:path";
export interface LockInfo {
pid: number;
startTime: number;
sessionId: string;
}
/**
* Check if a process is still alive.
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
const STALE_LOCK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
/**
* Try to acquire a named lock for this workspace.
* Uses O_CREAT|O_EXCL for atomic creation to avoid TOCTOU races.
* Returns true if acquired, false if another live session holds it.
*/
export function acquireLock(workspaceRoot: string, name: string, sessionId: string): boolean {
const locksDir = join(workspaceRoot, ".bemol", "locks");
const lockFile = join(locksDir, `${name}.lock`);
// Check existing lock — clean up stale locks from dead processes or old age
const existing = readLock(workspaceRoot, name);
if (existing && existing.pid !== process.pid && isProcessAlive(existing.pid)) {
// Process is alive, but check if the lock is older than 5 minutes (stale)
try {
const stat = statSync(lockFile);
const ageMs = Date.now() - stat.mtimeMs;
if (ageMs > STALE_LOCK_THRESHOLD_MS) {
// Lock is stale — remove it even though the process is alive
try { unlinkSync(lockFile); } catch { /* ignore */ }
} else {
return false; // another live session holds a fresh lock
}
} catch {
return false; // can't stat, assume lock is valid
}
}
// If stale lock exists, remove it first
if (existing) {
try { unlinkSync(lockFile); } catch { /* ignore */ }
}
// Acquire atomically using O_CREAT|O_EXCL (fails if file already exists)
try {
mkdirSync(locksDir, { recursive: true });
const info: LockInfo = { pid: process.pid, startTime: Date.now(), sessionId };
const content = JSON.stringify(info);
// O_WRONLY | O_CREAT | O_EXCL — atomic: fails with EEXIST if another process created it first
const fd = openSync(lockFile, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o644);
try {
writeFileSync(fd, content);
} finally {
closeSync(fd);
}
return true;
} catch (err: any) {
if (err.code === "EEXIST") {
// Another process acquired the lock between our unlink and open — that's fine
return false;
}
return false;
}
}
/**
* Release a lock owned by this process.
*/
export function releaseLock(workspaceRoot: string, name: string): void {
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
try {
const existing = readLock(workspaceRoot, name);
// Only delete if we own it
if (existing && existing.pid === process.pid) {
unlinkSync(lockFile);
}
} catch {
// ignore
}
}
/**
* Read the current lock holder, or null if no lock / stale lock.
*/
export function readLock(workspaceRoot: string, name: string): LockInfo | null {
const lockFile = join(workspaceRoot, ".bemol", "locks", `${name}.lock`);
try {
if (!existsSync(lockFile)) return null;
const content = readFileSync(lockFile, "utf-8");
const info = JSON.parse(content) as LockInfo;
if (!info.pid || !info.startTime) return null;
return info;
} catch {
return null;
}
}
/**
* Check if a lock is held by another live process (not us).
*/
export function isLockedByOther(workspaceRoot: string, name: string): boolean {
const info = readLock(workspaceRoot, name);
if (!info) return false;
if (info.pid === process.pid) return false;
return isProcessAlive(info.pid);
}
/**
* Release all locks owned by this process in the workspace.
*/
export function releaseAllLocks(workspaceRoot: string): void {
const locksDir = join(workspaceRoot, ".bemol", "locks");
try {
if (!existsSync(locksDir)) return;
const files: string[] = readdirSync(locksDir);
for (const file of files) {
if (!file.endsWith(".lock")) continue;
const name = file.replace(/\.lock$/, "");
releaseLock(workspaceRoot, name);
}
} catch {
// ignore
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
{
"name": "pi-lsp-extension",
"version": "1.3.0",
"type": "module",
"description": "Pi coding agent extension for LSP (Language Server Protocol) integration",
"keywords": [
"pi-package",
"extension"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/samfoy/pi-lsp-extension"
},
"scripts": {
"clean": "echo 'nothing to clean'",
"build": "echo 'nothing to build'",
"check": "echo 'nothing to check'"
},
"pi": {
"extensions": [
"./src/index.ts"
]
},
"dependencies": {
"tree-sitter-wasms": "^0.1.13",
"vscode-languageserver-protocol": "^3.17.5",
"web-tree-sitter": "^0.24.7"
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*",
"@sinclair/typebox": "*"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}

View File

@@ -0,0 +1,180 @@
# Code Actions Tool
## Summary
Add an `lsp_code_actions` tool that retrieves available code actions (quick fixes, refactorings, source actions) for a position or range in a file. This lets the LLM discover and apply IDE-level fixes — auto-imports, extract method, fix lint errors — that would otherwise require manual knowledge of the codebase.
## Motivation
When the LLM encounters a diagnostic error (e.g., "Cannot find name 'Foo'"), it currently has to reason about the fix from scratch. But the LSP server often already knows the fix — "Add import from './foo'" — via code actions. Exposing these saves time and reduces hallucination.
The `lsp-pi` competitor exposes `codeAction` as one of its actions and it covers:
- Quick fixes (auto-import, remove unused variable, fix typo)
- Refactorings (extract method, extract variable, inline variable)
- Source actions (organize imports, add missing members)
## Goals
- **`lsp_code_actions` tool** — return available actions for a position or diagnostic range
- **Diagnostic-aware** — when called with a position that has a diagnostic, include the diagnostic context so the server returns relevant fixes
- **Actionable output** — format actions so the LLM knows what each one does and can apply the edits
- **Preferred actions** — highlight preferred/auto-fix actions (the server marks these)
## Non-goals
- Executing code actions automatically — the tool returns the action list and their edits; the LLM uses `edit`/`write` to apply
- Command-based actions — some code actions return a `command` instead of `edit`. We report these but can't execute them (they require IDE integration)
- Resolving lazy actions — `codeAction/resolve` for actions that defer their edit computation. This can be a follow-up if needed.
## Design
### Tool definition
**`lsp_code_actions`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
| `endLine` | number? | End line for range selection (1-indexed). Defaults to `line`. |
| `endCharacter` | number? | End column for range selection (1-indexed). Defaults to `character`. |
| `kind` | string? | Filter by action kind (e.g., `"quickfix"`, `"refactor"`, `"source"`) |
### LSP request
```typescript
// Collect diagnostics at the position to include as context
const uri = manager.getFileUri(filePath);
const allDiags = client.getDiagnostics(uri);
const rangeDiags = allDiags.filter(d => rangeContainsPosition(d.range, line, character));
const result = await client.sendRequest<CodeAction[]>("textDocument/codeAction", {
textDocument: { uri },
range: {
start: { line: params.line - 1, character: params.character - 1 },
end: { line: (params.endLine ?? params.line) - 1, character: (params.endCharacter ?? params.character) - 1 },
},
context: {
diagnostics: rangeDiags,
only: params.kind ? [params.kind] : undefined,
},
});
```
Including matching diagnostics in the `context` is critical — many servers (jdtls, pyright, typescript-language-server) only return quick fixes when the relevant diagnostic is in the context.
### Output format
```
5 code actions at src/handler.ts:42:10
1. ★ Add import from './types' [quickfix]
Edits: src/handler.ts:1:1 insert "import { Foo } from './types';\n"
2. ★ Remove unused variable 'x' [quickfix]
Edits: src/handler.ts:42:3-42:15 delete
3. Extract to function in module scope [refactor.extract]
Edits: (resolve required)
4. Convert to arrow function [refactor.rewrite]
Edits: src/handler.ts:40:1-45:2 replace
5. Organize imports [source.organizeImports]
Edits: (resolve required)
```
Key formatting:
- **★** marks preferred actions (`isPreferred: true`) — these are the auto-fix candidates
- **Kind** shown in brackets — helps the LLM filter mentally
- **Edits** shown inline if the action includes a `WorkspaceEdit`. Format matches `lsp_rename` output
- **"resolve required"** for actions that have no edit but have a `data` field (need `codeAction/resolve`)
- Actions are sorted: preferred first, then by kind (quickfix → refactor → source → other)
### Details object
```typescript
interface CodeActionsDetails {
count: number;
preferredCount: number;
actions: Array<{
title: string;
kind?: string;
isPreferred: boolean;
hasEdit: boolean;
hasCommand: boolean;
}>;
}
```
### TUI rendering
**`renderCall`:**
```
lsp_code_actions src/handler.ts:42:10
```
With kind filter:
```
lsp_code_actions src/handler.ts:42:10 [quickfix]
```
With range:
```
lsp_code_actions src/handler.ts:42:10-45:2
```
**`renderResult` (collapsed):**
```
5 actions (2 preferred)
```
Or if no actions:
```
No code actions available
```
### Handling edge cases
- **No actions available** — return "No code actions available at this position."
- **Command-only actions** — report the title and note that it requires IDE execution: "This action requires IDE execution and cannot be applied via edit/write."
- **Large edit sets** — truncate edits to first 20 per action, note remaining count
- **No LSP server** — return standard unavailable reason
- **Server doesn't support code actions** — check `capabilities.codeActionProvider` and return a clear message
### File layout
```
src/tools/
└── code-actions.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to system prompt: "When `lsp_diagnostics` shows errors, try `lsp_code_actions` at the error position to find available quick fixes before writing a manual fix."
- The workflow is: `lsp_diagnostics` → see error → `lsp_code_actions` at error position → find auto-import fix → apply with `edit`
### Utility: `rangeContainsPosition`
Add a shared helper in `src/shared/format.ts`:
```typescript
export function rangeContainsPosition(
range: Range,
line: number, // 0-indexed
character: number, // 0-indexed
): boolean {
if (line < range.start.line || line > range.end.line) return false;
if (line === range.start.line && character < range.start.character) return false;
if (line === range.end.line && character > range.end.character) return false;
return true;
}
```
## Open questions
- **`codeAction/resolve`** — should we automatically resolve actions that have `data` but no `edit`? This adds a round-trip per action but gives the LLM the actual edits. Could be gated: resolve only preferred actions, or only when the action count is small (< 5).
- **Applying actions** — should the tool have an `apply` mode that applies a specific action's edits directly? Pro: simpler workflow. Con: the LLM can already use the edit tool with the provided edit locations. Lean towards keeping it read-only for v1, matching how `lsp_rename` works.
- **Interaction with diagnostics auto-append** — when the auto-diagnostics hook fires after a write/edit and shows errors, should it also hint "try lsp_code_actions at line X for quick fixes"? Potentially noisy.

View File

@@ -0,0 +1,102 @@
# Completions Tool
## Summary
Add an `lsp_completions` tool that returns completion suggestions at a given position in a file. This lets the LLM discover available methods, properties, and APIs on objects without having to read documentation or source code.
## Motivation
When writing code, the LLM often needs to know what methods are available on an object — e.g., "what can I call on `s3Client`?" or "what fields does this struct have?". Today it either guesses (risking hallucination) or has to read the source/type definitions manually.
LSP completion is exactly this: given a cursor position, the language server returns ranked suggestions with type signatures and documentation. Kiro CLI exposes this and it's one of their differentiators.
## Goals
- **`lsp_completions` tool** — return completion items at a position, with type signatures and docs
- **Smart triggering** — the LLM can ask "what methods are available on X" and get actionable results
- **Concise output** — return the top N results (default 20) with kind, label, type, and doc summary — not the full verbose LSP response
## Non-goals
- Snippet expansion or tab-completion UX — this is a query tool, not an interactive completion engine
- Commit characters, edit ranges, or other IDE-specific completion metadata
- Tree-sitter fallback — completions fundamentally require type information, so this is LSP-only
## Design
### Tool definition
**`lsp_completions`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
| `limit` | number? | Max results to return (default: 20) |
### Trigger workflow
The LLM typically uses this in one of two ways:
1. **Explore an API**: Write `s3Client.` in a file, then call `lsp_completions` at the position after the dot to see available methods.
2. **Verify a method exists**: Before calling `response.bodyAsString()`, check completions on `response.` to confirm the method name.
In both cases the file needs to be open in the LSP (handled by `FileSync`) and the content needs to reflect the current state.
### Synthetic trigger
A common case is "what methods does X have?" where X is already in the code but there's no trailing dot. The tool should support a convenience mode:
- If the position points to the end of an identifier, the tool temporarily inserts a `.` after it, requests completions, then removes it. This avoids forcing the LLM to edit the file just to explore an API.
- This is optional and gated by a `trigger` parameter (`"auto"` | `"none"`, default `"auto"`).
### Output format
```
20 completions at src/handler.ts:42:15
method getObject(params: GetObjectRequest): Promise<GetObjectOutput>
Retrieves an object from S3.
method putObject(params: PutObjectRequest): Promise<PutObjectOutput>
Uploads an object to S3.
property region: string
The AWS region for this client.
...
```
Each item shows:
- **Kind** — method, property, function, variable, class, keyword, etc.
- **Label + signature** — from `detail` or `labelDetails` in the LSP response
- **Documentation** — first 1-2 lines of the doc comment, if available
Items are sorted by the LSP server's `sortText` ranking (which considers scope, type match, and usage frequency).
### Resolve for details
Many LSP servers return minimal items in the initial response and require a `completionItem/resolve` call for documentation and full signatures. The tool should:
1. Request completions at the position
2. For the top N items, call `completionItem/resolve` in parallel to get full details
3. Merge results and format output
This adds latency but dramatically improves output quality. Cap resolve calls to the `limit` parameter to bound cost.
### File layout
```
src/tools/
└── completions.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to the extension's system prompt snippet: mention that `lsp_completions` is available for discovering methods and properties
- No tree-sitter fallback — return a clear message if no LSP server is running
## Open questions
- **Synthetic trigger safety** — temporarily modifying file content to insert a `.` could cause issues if the LSP processes the change and emits diagnostics before we revert. Should we use a separate virtual document, or is the insert-request-revert cycle fast enough to be safe?
- **Performance** — `completionItem/resolve` for 20 items could be slow on some servers. Should we resolve lazily (only top 5) or make it configurable?
- **Filter parameter** — should the tool accept a `filter` string to pre-filter results (e.g., only methods, only properties)? Or is that over-engineering for v1?

View File

@@ -0,0 +1,152 @@
# Custom TUI Rendering
## Summary
Add `renderCall` and `renderResult` methods to the two tools that currently lack them (`code_search` and `code_rewrite`), bringing all tools to parity with the rendering quality of the LSP tools.
## Motivation
The `lsp-pi` competitor has polished TUI rendering for its unified `lsp` tool — themed headers, collapsible result previews, match counts. Our LSP tools already have good rendering (hover, diagnostics, rename, etc.), but `code_search` and `code_rewrite` fall back to the default pi rendering which dumps raw JSON-ish output.
These are high-visibility tools — structural search results and rewrite previews benefit significantly from custom formatting.
## Goals
- **`code_search`**: show a compact one-liner for the call (pattern + language + scope), and a results view with match counts, file grouping, and highlighted captured metavariables
- **`code_rewrite`**: show the call as pattern → replacement, and results as a diff-style before/after preview with file counts
- **Consistent style** — match the rendering patterns used by the existing LSP tools (themed labels, accent colors for paths, dim for metadata, success/error for status)
## Non-goals
- Interactive expansion/collapse of individual matches — pi's TUI doesn't support that level of interactivity in tool results
- Syntax highlighting of matched code — theme colors only (muted, accent, etc.), not language-aware highlighting
## Design
### `code_search` rendering
#### `renderCall`
```
code_search console.log($ARG) typescript src/
```
Format: `{toolTitle bold} {pattern accent} {language dim} {path dim}`
If no path is given, omit it (workspace root is the default).
#### `renderResult`
**Collapsed (default):**
```
23 matches in 8 files
```
Use `success` color if matches found, `dim` if zero matches.
**Expanded:**
```
23 matches in 8 files
src/handler.ts:
42:5 console.log(response) $ARG = response
67:3 console.log("done") $ARG = "done"
src/utils.ts:
12:1 console.log(err.message) $ARG = err.message
...
```
Group by file, show line:col, matched text (truncated to ~60 chars), and metavariable bindings. Cap at 20 matches in collapsed view, show all in expanded.
### `code_rewrite` rendering
#### `renderCall`
```
code_rewrite var $N = $V → const $N = $V javascript [dry-run]
```
Format: `{toolTitle bold} {pattern accent} → {replacement accent} {language dim} {mode dim}`
Show `[dry-run]` or `[apply]` based on the `dry_run` parameter.
#### `renderResult`
**Collapsed (default):**
```
12 replacements in 5 files (dry-run)
```
Or after apply:
```
✓ 12 replacements applied in 5 files
```
**Expanded:**
```
12 replacements in 5 files (dry-run)
src/handler.ts:
42: - var count = 0
+ const count = 0
67: - var name = "foo"
+ const name = "foo"
src/utils.ts:
12: - var x = arr.length
+ const x = arr.length
...
```
Use `error` color for `-` lines, `success` color for `+` lines. Cap preview at 10 replacements collapsed, all in expanded.
### Implementation
Both tools already return structured `details` objects — the rendering just needs to read from them.
#### `code_search` details shape (existing)
```typescript
interface CodeSearchDetails {
matchCount: number;
fileCount: number;
matches: Array<{
file: string;
line: number;
column: number;
text: string;
captures: Record<string, string>;
}>;
}
```
#### `code_rewrite` details shape (existing)
```typescript
interface CodeRewriteDetails {
replacementCount: number;
fileCount: number;
dryRun: boolean;
replacements: Array<{
file: string;
line: number;
before: string;
after: string;
}>;
}
```
If the current details shapes don't have all these fields, extend them as needed.
### File changes
- `src/tools/code-search.ts` — add `renderCall` and `renderResult` methods
- `src/tools/code-rewrite.ts` — add `renderCall` and `renderResult` methods
No new files needed.
## Open questions
- **Theme tokens** — the existing tools use `toolTitle`, `accent`, `dim`, `muted`, `success`, `error`, `warning`. Are there additional tokens available for diff-style rendering (e.g., `added`, `removed`)?
- **Expanded state** — does pi pass `expanded` in the `renderResult` options? Need to verify the `RenderResultOptions` type. The diagnostics tool already checks `options.expanded`.

View File

@@ -0,0 +1,249 @@
# Query-Based Position Resolution
## Summary
Add a `query` parameter to position-based tools (`lsp_hover`, `lsp_definition`, `lsp_references`, `lsp_rename`, `lsp_signature_help`, `lsp_code_actions`) that resolves a symbol name to a file position, eliminating the need for the LLM to know exact line/column numbers when it just wants to query a known symbol.
## Motivation
Today, to hover over a function named `handleRequest`, the LLM must:
1. Know or look up the exact line and column where `handleRequest` is defined or used
2. Call `lsp_hover` with `path`, `line`, `character`
This is friction. The LLM often knows the symbol name but not the exact position. It has to call `lsp_symbols` first to find the position, then call the actual tool — two round-trips.
The `lsp-pi` competitor solves this with a `query` parameter: pass `query: "handleRequest"` and it resolves the position via document symbols before making the LSP request. This is a significant UX improvement.
## Goals
- **Optional `query` parameter** on all position-based tools
- **Symbol name resolution** — find the symbol's position in the file via document symbols (LSP) or tree-sitter
- **Fallback chain** — try LSP document symbols first, then tree-sitter symbol extraction
- **Exact > partial** — exact name match wins over substring match
- **Transparent** — when `query` is used, include the resolved position in the output so the LLM knows where it landed
## Non-goals
- Workspace-wide symbol search via query — this is file-scoped resolution. Use `lsp_symbols` with a `query` for workspace search.
- Fuzzy matching — exact and substring only. Fuzzy introduces ambiguity.
- Replacing `line`/`character` — query is an alternative, not a replacement. When both are provided, `line`/`character` take precedence.
## Design
### Parameter changes
Add to all position-based tool schemas:
```typescript
query: Type.Optional(Type.String({
description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically."
})),
```
Tools that gain the `query` parameter:
- `lsp_hover`
- `lsp_definition`
- `lsp_references`
- `lsp_rename`
- `lsp_signature_help` (new tool)
- `lsp_code_actions` (new tool)
### Resolution logic
Create a shared resolver in `src/shared/resolve-position.ts`:
```typescript
export interface ResolvedPosition {
line: number; // 0-indexed (LSP convention)
character: number; // 0-indexed
symbolName: string; // the matched symbol name
source: "lsp" | "tree-sitter";
}
export async function resolveSymbolPosition(
filePath: string,
query: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<ResolvedPosition | null> {
// 1. Try LSP document symbols
const client = manager.getRunningClient(manager.getLanguageId(filePath) ?? "");
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbol[]>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
const match = findSymbolPosition(symbols, query);
if (match) return { ...match, source: "lsp" };
} catch { /* fall through */ }
}
// 2. Try tree-sitter
if (treeSitter) {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const match = findSymbolInTree(tree, query);
if (match) return { ...match, source: "tree-sitter" };
}
}
return null;
}
```
### Symbol matching (`findSymbolPosition`)
Walks the document symbol tree (recursive for children) and matches:
1. **Exact match**`symbol.name === query` (case-sensitive)
2. **Case-insensitive exact**`symbol.name.toLowerCase() === query.toLowerCase()`
3. **Substring match**`symbol.name.toLowerCase().includes(query.toLowerCase())`
Returns the first match in priority order. Uses `selectionRange.start` (preferred) or `range.start` for position.
### Tree-sitter symbol matching (`findSymbolInTree`)
Uses the existing `extractSymbols` function from `symbol-extractor.ts`:
1. Extract all symbols from the tree
2. Match using the same priority chain as above
3. Return the symbol's start position
### Tool integration pattern
Each tool's `execute` function gains a preamble:
```typescript
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line + 1; // convert to 1-indexed
character = resolved.character + 1;
resolvedFrom = `Resolved "${params.query}" → ${line}:${character} [${resolved.source}]`;
} else {
return {
content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}` }],
details: { hasResult: false },
};
}
}
if (line === undefined || character === undefined) {
return {
content: [{ type: "text", text: "Either line/character or query is required." }],
details: { hasResult: false },
};
}
// ... existing tool logic using line/character ...
// Prepend resolved position to output if query was used
if (resolvedFrom) {
const existingText = result.content[0]?.text ?? "";
result.content[0] = { type: "text", text: `${resolvedFrom}\n\n${existingText}` };
}
return result;
}
```
### Validation rules
- If both `line`/`character` and `query` are provided: use `line`/`character`, ignore `query`
- If neither is provided: return error "Either line/character or query is required."
- If `query` is provided but doesn't match any symbol: return error with the file's top-level symbol names as a hint
- `line` and `character` remain individually required when `query` is not used (can't provide just `line` without `character`)
### Parameter schema update
The `line` and `character` parameters change from required to optional:
```typescript
// Before
line: Type.Number({ description: "Line number (1-indexed)" }),
character: Type.Number({ description: "Column number (1-indexed)" }),
// After
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character." })),
```
### `renderCall` updates
When query is used instead of line/character:
```
lsp_hover src/handler.ts query="handleRequest"
```
When line/character is used (unchanged):
```
lsp_hover src/handler.ts:42:10
```
### System prompt update
Add a guideline:
```
Position-based LSP tools (lsp_hover, lsp_definition, lsp_references, lsp_rename, lsp_signature_help, lsp_code_actions) accept either line/character or a query parameter. Use query when you know the symbol name but not the exact position — it resolves the position automatically via document symbols.
```
### File layout
```
src/shared/
└── resolve-position.ts # New: shared position resolver
src/tools/
├── hover.ts # Modified: add query param
├── definition.ts # Modified: add query param
├── references.ts # Modified: add query param
├── rename.ts # Modified: add query param
├── signature-help.ts # New tool: includes query param
└── code-actions.ts # New tool: includes query param
```
## Examples
### Hover by symbol name
```
lsp_hover path="src/handler.ts" query="handleRequest"
→ Resolved "handleRequest" → 42:10 [lsp]
→ function handleRequest(req: Request): Promise<Response>
```
### Find references by name
```
lsp_references path="src/types.ts" query="UserConfig"
→ Resolved "UserConfig" → 15:14 [tree-sitter]
→ 12 references in 5 files
```
### Rename by name
```
lsp_rename path="src/utils.ts" query="formatDate" newName="formatDateTime"
→ Resolved "formatDate" → 8:17 [lsp]
→ Rename "formatDateTime": 23 edit(s) across 7 file(s)
```
## Open questions
- **Ambiguity** — what if a file has multiple symbols with the same name (e.g., overloaded methods, or a variable and a type with the same name)? Current design returns the first exact match. Should we return all matches and let the LLM pick? Or is first-match good enough?
- **Nested symbols** — `findSymbolPosition` walks children recursively, so `query="render"` could match a top-level `render` function or a `render` method inside a class. Should class-qualified names be supported (e.g., `query="MyComponent.render"`)?
- **Performance** — requesting document symbols adds a round-trip before the actual request. For LSP, this is typically fast (<50ms). For tree-sitter, it's a file parse. Acceptable for the ergonomic benefit.
- **`lsp_completions`** — should completions also support query? Less clear — completions are about a cursor position in code, not a symbol name. Leaving out for now.

View File

@@ -0,0 +1,126 @@
# Signature Help Tool
## Summary
Add an `lsp_signature_help` tool that returns function/method signature information at a call site — parameter names, types, documentation, and which parameter is currently active. This complements `lsp_hover` (which shows type info for a symbol) and `lsp_completions` (which lists available members).
## Motivation
When the LLM is writing a function call with multiple parameters, it needs to know the parameter order, types, and what each parameter does. Today it either:
1. Calls `lsp_hover` on the function name — gets the full signature but no active-parameter context
2. Reads the source/docs — expensive and slow
3. Guesses — risks hallucination
LSP's `textDocument/signatureHelp` is designed exactly for this: given a cursor position inside a call's argument list, it returns the signature with the active parameter highlighted. The `lsp-pi` competitor already exposes this as its `signature` action.
## Goals
- **`lsp_signature_help` tool** — return the active signature, parameters, and documentation at a call site position
- **Active parameter highlighting** — clearly indicate which parameter the cursor is on
- **Multiple overloads** — when a function has overloads, show the active one and list alternatives
- **Clean output** — format for LLM consumption (not raw LSP JSON)
## Non-goals
- Trigger character detection (e.g., auto-invoke on `(` or `,`) — this is a query tool, not an IDE feature
- Tree-sitter fallback — signature help fundamentally requires type resolution; no useful fallback possible
- Retrigger logic — the LSP retrigger context is for interactive editors; we do a single request
## Design
### Tool definition
**`lsp_signature_help`**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `line` | number | Line number (1-indexed) |
| `character` | number | Column number (1-indexed) |
No `query` parameter — signature help is inherently position-based (cursor must be inside the argument list).
### LSP request
```typescript
const result = await client.sendRequest<SignatureHelp | null>("textDocument/signatureHelp", {
textDocument: { uri },
position: { line: params.line - 1, character: params.character - 1 },
});
```
### Output format
```
Signature 1 of 2 (active):
createReadStream(path: PathLike, options?: ReadStreamOptions): ReadStream
Parameters:
→ path: PathLike — The path to the file to read
options?: ReadStreamOptions — Options for the stream (encoding, start, end, highWaterMark)
Returns: ReadStream
Signature 2 of 2:
createReadStream(path: PathLike, encoding: BufferEncoding): ReadStream
```
Key formatting rules:
- Show all signatures, mark the active one (`activeSignature` index from LSP response)
- For the active signature, show all parameters with the active one marked with `→`
- Include parameter documentation if available (from `SignatureInformation.parameters[].documentation`)
- Include signature-level documentation if available (from `SignatureInformation.documentation`)
- Truncate long documentation to 2 lines per parameter
### Handling edge cases
- **No signature help available** — return "No signature help available at this position. Cursor must be inside a function call's argument list."
- **Empty parameters** — show signature line but skip the parameters section
- **Missing documentation** — show parameters with types only, skip doc lines
- **Multiple overloads** — show all, sorted by the `activeSignature` first
- **No LSP server running** — return the standard unavailable reason message
### Details object
```typescript
interface SignatureHelpDetails {
hasResult: boolean;
signatureCount: number;
activeSignature: number;
activeParameter: number;
}
```
### TUI rendering
**`renderCall`:**
```
lsp_signature_help src/handler.ts:42:15
```
**`renderResult` (collapsed):**
```
createReadStream(path, options?) — param 2 of 2
```
Show the function name and active parameter position. If no result, show "No signature help" in dim.
### File layout
```
src/tools/
└── signature-help.ts
```
### Integration
- Register in `index.ts` alongside existing tools
- Add to system prompt snippet: "Use `lsp_signature_help` when you need to know the parameter order or types for a function call"
- No tree-sitter fallback — return clear message when no LSP is available
## Open questions
- **Naming** — `lsp_signature_help` vs `lsp_signature` vs `lsp_params`? The LSP protocol calls it "signature help" so `lsp_signature_help` is most precise, but it's verbose. The competitor uses `signature` (as an action on the unified tool). Going with `lsp_signature_help` for consistency with `lsp_diagnostics`, `lsp_definition`, etc.
- **Context parameter** — should we accept an optional `context` with `triggerKind` and `triggerCharacter`? Probably not — the LLM doesn't know or care about trigger characters. Always send `triggerKind: Invoked`.
- **Interaction with completions** — when the LLM is inside a call and wants to know the expected type for the current argument, should it use signature help (to see the parameter type) then completions (to see what values match)? The system prompt should guide this workflow.

View File

@@ -0,0 +1,142 @@
# Structural Code Search & Rewrite
## Summary
Add AST-based structural search and rewrite tools powered by tree-sitter's query language. Find and transform code by structure, not text — enabling safe, language-aware refactoring that grep and sed can't do.
## Motivation
Text-based search (`grep`, `rg`) matches strings, not code structure. Searching for `console.log` catches comments and string literals. Replacing `var` with `const` via regex can break template literals and object keys.
Kiro CLI offers `pattern_search` and `pattern_rewrite` with metavariable support (`$VAR` matches any single node, `$$$` matches zero-or-more). This is genuinely useful for the LLM — it can propose structural transformations and preview them safely.
## Goals
- **`code_search` tool** — find code matching a structural pattern, returning locations and matched fragments
- **`code_rewrite` tool** — transform code matching a pattern into a replacement pattern, with dry-run support
- **Metavariable syntax** — `$NAME` for single nodes, `$$$` for variadic sequences, matching Kiro's convention
- **Multi-language** — works for any language with a tree-sitter grammar loaded (see tree-sitter-integration spec)
## Non-goals
- Type-aware matching (e.g., "find all calls where the argument is a `string`") — that needs LSP
- Cross-file rewrite coordination (e.g., updating imports when renaming) — use `lsp_rename` for that
- Custom query language exposure — we translate metavariable patterns to tree-sitter queries internally
## Depends on
- [Tree-sitter Integration](./tree-sitter-integration.md) — requires the parser-manager and grammar loading infrastructure
## Design
### Pattern syntax
User-facing patterns use a simplified syntax with metavariables:
```
// Single node capture
console.log($ARG) → matches console.log(x), console.log("hello"), etc.
// Variadic capture
function $NAME($$$PARAMS) { $$$ } → matches any function declaration
// Literal matching
$OBJ.hasOwnProperty($KEY) → matches foo.hasOwnProperty("bar")
```
Internally, patterns are compiled to tree-sitter S-expression queries with `@capture` nodes.
### Pattern compilation
1. Parse the pattern string as code in the target language using tree-sitter
2. Walk the resulting AST and replace metavariable identifiers with query captures:
- `$NAME``(_) @name` (single node wildcard)
- `$$$PARAMS``(_)* @params` (variadic wildcard)
- Literal nodes stay as concrete matchers
3. Produce a tree-sitter query string
Edge cases:
- Pattern doesn't parse → try wrapping in expression context, then statement context
- Ambiguous parse → return error with suggestion to be more specific
### Tools
#### `code_search`
| Parameter | Type | Description |
|-----------|------|-------------|
| `pattern` | string | Structural pattern with metavariables |
| `language` | string | Target language (typescript, python, rust, etc.) |
| `path` | string? | File or directory to search (default: workspace root) |
| `max_results` | number? | Cap results (default: 50) |
Returns a list of matches with:
- File path, line number, column
- Matched source text
- Captured metavariable bindings (e.g., `$NAME = "fetchUser"`)
#### `code_rewrite`
| Parameter | Type | Description |
|-----------|------|-------------|
| `pattern` | string | Pattern to match |
| `replacement` | string | Replacement pattern using same metavariables |
| `language` | string | Target language |
| `path` | string? | File or directory scope |
| `dry_run` | boolean? | Preview changes without applying (default: true) |
Returns:
- In dry-run mode: list of planned changes (file, line, before → after)
- In apply mode: summary of changes made, number of files modified
The tool applies changes via pi's `write` internally (so auto-diagnostics fire and the LLM sees any errors introduced).
### Matching engine
For each file in scope:
1. Parse with tree-sitter (reuse cached trees from workspace index)
2. Run the compiled query against the tree
3. Collect matches, extract captured nodes
4. For rewrites: reconstruct the replacement by substituting captured text into the replacement pattern
5. Apply replacements bottom-up (last match first) to preserve byte offsets
### File layout
```
src/
├── tree-sitter/
│ ├── pattern-compiler.ts # Metavariable pattern → tree-sitter query
│ ├── search-engine.ts # Run queries across files, collect matches
│ └── rewrite-engine.ts # Apply replacement patterns
└── tools/
├── code-search.ts
└── code-rewrite.ts
```
## Examples
### Find all `.unwrap()` calls in Rust
```
pattern: $E.unwrap()
language: rust
```
### Convert `var` to `const` in JavaScript
```
pattern: var $N = $V
replacement: const $N = $V
language: javascript
```
### Find async functions with no await
```
pattern: async function $NAME($$$) { $$$ }
language: typescript
```
(Post-filter: check that no captured body node contains `await` — this may need a two-pass approach or a `filter` parameter in a future iteration.)
## Open questions
- **Pattern ambiguity** — some patterns could match at multiple AST levels (expression vs. statement). Should we default to the most specific match, or let the user specify?
- **Replacement formatting** — after substitution, should we auto-format the result (e.g., via `prettier` or LSP formatting)? Or leave as-is?
- **Conflict with `lsp_rename`** — structural rewrite overlaps with rename for simple cases. The system prompt should guide the LLM: use `lsp_rename` for symbol renames (semantically correct), use `code_rewrite` for structural transformations.

View File

@@ -0,0 +1,286 @@
# Test Suite
## Summary
Add a comprehensive test suite covering the extension's core modules: LSP client, LSP manager, file sync, tree-sitter engines, tool implementations, and the extension entry point. Tests should run without requiring real LSP servers (except for optional integration tests).
## Motivation
The extension has grown to ~6,000 lines across 20+ source files with no automated tests. Both competing LSP extensions have similar gaps — `lsp-pi` is the exception with ~1,700 lines of tests. Without tests, refactoring is risky and regressions are invisible until a user hits them.
## Goals
- **Unit tests** for all core modules with mocked LSP connections
- **Integration tests** (optional, gated behind `--integration`) that spin up real LSP servers
- **Test runner** that works with `npm test` out of the box
- **CI-friendly** — no flaky timeouts, no filesystem side effects, deterministic output
## Non-goals
- 100% coverage — focus on logic-heavy modules and known edge cases
- Testing the pi extension API itself — we trust the framework
- Testing TUI rendering pixel-perfectly — just verify the render functions return `Text` nodes
## Design
### Test framework
Use **vitest** — it supports TypeScript natively (no build step), has built-in mocking, and is fast. Add to `devDependencies`.
```json
{
"devDependencies": {
"vitest": "^3.0.0"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --project integration"
}
}
```
### File layout
```
tests/
├── unit/
│ ├── lsp-client.test.ts
│ ├── lsp-manager.test.ts
│ ├── file-sync.test.ts
│ ├── resolve-provider.test.ts
│ ├── tree-sitter/
│ │ ├── parser-manager.test.ts
│ │ ├── pattern-compiler.test.ts
│ │ ├── search-engine.test.ts
│ │ ├── rewrite-engine.test.ts
│ │ ├── symbol-extractor.test.ts
│ │ └── workspace-index.test.ts
│ └── tools/
│ ├── diagnostics.test.ts
│ ├── hover.test.ts
│ ├── definition.test.ts
│ ├── references.test.ts
│ ├── symbols.test.ts
│ ├── rename.test.ts
│ ├── completions.test.ts
│ ├── code-search.test.ts
│ ├── code-rewrite.test.ts
│ ├── code-overview.test.ts
│ ├── signature-help.test.ts # new tool
│ └── code-actions.test.ts # new tool
├── integration/
│ ├── typescript-server.test.ts
│ └── python-server.test.ts
├── fixtures/
│ ├── sample.ts
│ ├── sample.py
│ ├── sample.rs
│ └── sample-project/
│ ├── package.json
│ ├── src/
│ │ ├── index.ts
│ │ └── utils.ts
│ └── tsconfig.json
└── helpers/
├── mock-lsp-client.ts
├── mock-lsp-manager.ts
└── test-utils.ts
```
### Mock strategy
#### `MockLspClient`
A fake `LspClient` that:
- Records all `sendRequest` calls with method + params
- Returns pre-configured responses per method (set via `mockResponse(method, response)`)
- Stores and returns diagnostics via `getDiagnostics(uri)` / `getAllDiagnostics()`
- Tracks `didOpen` / `didChange` notifications
```typescript
class MockLspClient {
private responses = new Map<string, unknown>();
private diagnostics = new Map<string, Diagnostic[]>();
private requests: Array<{ method: string; params: unknown }> = [];
mockResponse(method: string, response: unknown) { ... }
setDiagnostics(uri: string, diags: Diagnostic[]) { ... }
async sendRequest<R>(method: string, params: unknown): Promise<R> {
this.requests.push({ method, params });
return this.responses.get(method) as R;
}
getRequests(method?: string) { ... }
}
```
#### `MockLspManager`
Wraps `MockLspClient` and provides:
- `getClientForFile(path)` → returns the mock client
- `getFileUri(path)` / `resolvePath(path)` → deterministic paths
- `getLanguageId(path)` → based on extension
- `getRunningClient(languageId)` → returns the mock client
- `getUnavailableReason(path)` → returns a test message
### Test categories
#### 1. LSP Client (`lsp-client.test.ts`)
- JSON-RPC message framing (Content-Length header parsing)
- Request/response correlation (matching IDs)
- Notification handling (`textDocument/publishDiagnostics`)
- Connection error handling and reconnection
- Graceful shutdown sequence
- Timeout behavior for pending requests
#### 2. LSP Manager (`lsp-manager.test.ts`)
- Server config resolution (built-in defaults, custom overrides)
- Language detection from file extension
- Lazy server startup (only when `getClientForFile` is called)
- File URI generation (handles spaces, special characters)
- Multiple servers for different languages
- Brazil workspace detection and bemol integration
- Daemon mode (shared server lifecycle)
#### 3. File Sync (`file-sync.test.ts`)
- `handleFileRead` → sends `didOpen` with correct URI and content
- `handleFileWrite` → sends `didChange` with incremented version
- Deduplication (multiple reads of the same file don't re-open)
- Version tracking consistency
- Tree-sitter integration (parse/index updates on file change)
#### 4. Tree-sitter modules
**parser-manager.test.ts**
- WASM init lifecycle
- Parse TypeScript, Python, Rust, Go, Java source
- Incremental re-parse after edit
- Unknown language handling
**pattern-compiler.test.ts**
- `$NAME` single-node metavariable compilation
- `$$$NAME` variadic metavariable compilation
- Literal node preservation
- Expression wrapping for partial patterns
- Error cases (unparseable patterns)
**search-engine.test.ts**
- Single-file pattern matching
- Multi-file directory scanning
- Metavariable capture extraction
- `max_results` limiting
- `.gitignore` respecting
**rewrite-engine.test.ts**
- Simple substitution (`$A → $A`)
- Multi-capture replacement (`$A.$B → $B.$A`)
- Variadic replacement (`$$$ARGS`)
- Bottom-up application (offset preservation)
- Dry-run vs. apply modes
**symbol-extractor.test.ts**
- Function/method/class/interface extraction per language
- Nested symbol handling (methods inside classes)
- Signature text extraction
- Syntax error detection
**workspace-index.test.ts**
- Index build from fixture project
- Symbol search (exact and fuzzy)
- Incremental update on file change
- Skip patterns (`node_modules`, `.git`, etc.)
#### 5. Tool tests
Each tool test follows the same pattern:
1. Create a `MockLspManager` with pre-configured responses
2. Call the tool's `execute` function with test params
3. Assert on the returned `content` text and `details` object
4. Verify `renderCall` returns a `Text` node with expected content
5. Verify `renderResult` returns a `Text` node for both partial and complete states
Example for `hover.test.ts`:
```typescript
describe("lsp_hover", () => {
it("formats MarkupContent hover response", async () => {
const mockManager = createMockManager();
mockManager.client.mockResponse("textDocument/hover", {
contents: { kind: "markdown", value: "```ts\nfunction foo(): void\n```" },
});
const tool = createHoverTool(mockManager);
const result = await tool.execute("call-1", { path: "test.ts", line: 5, character: 10 });
expect(result.content[0].text).toContain("function foo(): void");
expect(result.details.hasResult).toBe(true);
});
it("falls back to tree-sitter when no LSP", async () => { ... });
it("returns unavailable message for unknown language", async () => { ... });
it("handles LSP error gracefully", async () => { ... });
it("renderCall shows file:line:col", () => { ... });
it("renderResult shows truncated hover on collapse", () => { ... });
});
```
#### 6. Integration tests
Gated behind `--project integration` flag. Require real servers installed:
**typescript-server.test.ts**
- Start `typescript-language-server`
- Open a TypeScript file
- Get diagnostics, hover, definition, references, completions
- Verify results against known fixture content
- Clean shutdown
**python-server.test.ts**
- Start `pyright-langserver`
- Same test pattern for Python fixtures
These tests have a 30-second timeout and skip if the server binary isn't found.
### Configuration
`vitest.config.ts`:
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/unit/**/*.test.ts"],
testTimeout: 10000,
projects: [
{
name: "unit",
include: ["tests/unit/**/*.test.ts"],
},
{
name: "integration",
include: ["tests/integration/**/*.test.ts"],
testTimeout: 30000,
},
],
},
});
```
### Coverage targets
- Unit tests: aim for 80%+ line coverage on core modules
- Tools: every tool should have at least 3 tests (happy path, error, edge case)
- Tree-sitter: heavy coverage on pattern-compiler and search-engine (complex logic)
- Integration: smoke-test level — verify end-to-end flow works
## Open questions
- **Snapshot testing** — should `renderCall`/`renderResult` tests use snapshot assertions for the `Text` output? Pros: catches regressions in formatting. Cons: noisy diffs when theme changes.
- **Fixture management** — should fixtures be inline strings or separate files? Inline is easier to read in tests; files are better for integration tests that need real project structure.
- **Mock fidelity** — how closely should `MockLspClient` mimic real JSON-RPC behavior? A thin mock (just record/playback) is easier to maintain but may miss framing bugs.

View File

@@ -0,0 +1,82 @@
# Tree-sitter Integration
## Summary
Add a tree-sitter layer that provides code intelligence without requiring an LSP server. This gives the extension useful symbol search, document symbols, and definition lookup out of the box for any supported language — no install step needed.
## Motivation
Today every tool in the extension requires a running LSP server. If the user hasn't installed `typescript-language-server` or `pyright`, they get nothing. Kiro CLI solves this with a built-in tree-sitter layer that covers 18 languages with zero setup.
Tree-sitter parsing is fast (incremental), works on syntactically broken files, and can power symbol search, document outlines, and basic go-to-definition without any external process.
## Goals
- **Zero-config code intelligence** — symbol search, document symbols, and definition lookup work immediately for supported languages
- **Graceful upgrade** — when an LSP server _is_ running, LSP results take priority; tree-sitter is the fallback
- **Codebase overview** — expose a tool that summarizes project structure (directories, key files, entry points) using tree-sitter symbol extraction
- **Minimal footprint** — tree-sitter WASM bindings, no native compilation required at install time
## Non-goals
- Replacing LSP — tree-sitter cannot do cross-file type resolution, find-references across files, or rename refactoring
- Supporting every language immediately — start with the languages we already support via LSP (TypeScript, JavaScript, Python, Rust, Go, Java) and expand later
## Design
### Parser management
- Use `web-tree-sitter` (WASM-based) so the extension works without native build tools
- Bundle or lazy-download `.wasm` grammar files for each supported language
- Cache parsed trees per-file; invalidate on `didChange` events from `FileSync`
- Reuse the `EXT_TO_LANGUAGE` mapping in `lsp-manager.ts` to detect language from file extension
### Fallback strategy
Each tool that currently calls the LSP checks whether a server is running for the file's language:
1. If LSP server is running and healthy → use LSP (current behavior)
2. If no LSP server → fall back to tree-sitter
3. If tree-sitter has no grammar for the language → return "no intelligence available" error
This logic lives in a shared `resolveProvider(filePath)` helper that tools call instead of going straight to `manager.getClientForFile()`.
### New tools / tool enhancements
| Tool | Tree-sitter behavior |
|------|---------------------|
| `lsp_symbols` (file) | Walk the tree-sitter AST for function, class, method, interface, enum, and variable declarations. Same output format as today. |
| `lsp_symbols` (workspace) | Scan project files (respecting `.gitignore`), parse each, and collect top-level symbols. Fuzzy-match against the query. Cache the index and update incrementally. |
| `lsp_definition` | For a symbol at a position, search the current file's tree for a matching definition node. If not found, search the workspace index. Best-effort — won't resolve imports through `node_modules` or type aliases. |
| `lsp_hover` | Extract the enclosing node's kind and text (e.g., "function declaration", the signature line). No type info — that requires LSP. |
| `lsp_diagnostics` | Tree-sitter can detect parse errors (syntax errors). Report those as a minimal diagnostic set when no LSP is available. |
| `code_overview` | **New tool.** Summarize project structure: directory tree, top-level symbols per file, entry points, dependency manifests. Uses tree-sitter for symbol extraction. |
### Workspace indexing
- On first use, walk the project tree (skip `node_modules`, `.git`, `build`, `dist`, `target`, etc.)
- Parse each file with tree-sitter, extract top-level symbols (name, kind, file, line)
- Store in an in-memory index keyed by symbol name
- Re-index individual files when `FileSync` reports changes
- For large repos, cap initial indexing at ~5000 files and index remaining on demand
### File layout
```
src/
├── tree-sitter/
│ ├── parser-manager.ts # Load/cache WASM parsers per language
│ ├── symbol-extractor.ts # AST → symbol list, per-language queries
│ ├── workspace-index.ts # Project-wide symbol index
│ └── grammars/ # .wasm files (or download script)
├── resolve-provider.ts # LSP-or-tree-sitter routing
└── tools/
├── code-overview.ts # New tool
└── ... (existing tools updated to use resolveProvider)
```
## Open questions
- **Grammar distribution** — bundle `.wasm` files in the repo (adds ~2-5 MB) or download on first use? Bundling is simpler; downloading keeps the repo small.
- **Incremental indexing performance** — is the in-memory index fast enough for monorepos with 10k+ files, or do we need SQLite/a persistent cache?
- **Query language** — tree-sitter has a built-in query language for pattern matching. Should we expose that as a user-facing feature here, or save it for the structural search spec?

View File

@@ -0,0 +1,196 @@
/**
* File Sync — keeps LSP servers informed of file changes.
*
* Hooks into pi tool results for read/write/edit and sends
* didOpen/didChange notifications to the appropriate LSP server.
*
* Maintains an LRU-bounded set of tracked documents. When the limit is
* reached, the least-recently-used document is closed via didClose to
* prevent unbounded memory growth in the LSP server during long sessions.
*/
import { readFile } from "node:fs/promises";
import { LspManager } from "./lsp-manager.js";
import type { TreeSitterManager } from "./tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "./tree-sitter/workspace-index.js";
/** Callback to check if a synthetic dot operation is in progress for a URI */
export type SyntheticDotChecker = (uri: string) => boolean;
/** Max open documents tracked simultaneously. Oldest are closed via didClose. */
const MAX_TRACKED_DOCUMENTS = 100;
interface TrackedDocument {
uri: string;
languageId: string;
version: number;
}
export class FileSync {
/** LRU map: most-recently-used documents are at the end (Map preserves insertion order) */
private tracked: Map<string, TrackedDocument> = new Map();
private treeSitter: TreeSitterManager | null = null;
private workspaceIndex: WorkspaceIndex | null = null;
private isSyntheticDotActive: SyntheticDotChecker = () => false;
private maxTracked: number;
constructor(private manager: LspManager, maxTracked?: number) {
this.maxTracked = maxTracked ?? MAX_TRACKED_DOCUMENTS;
}
/** Set the synthetic dot checker to coordinate with the completions tool */
setSyntheticDotChecker(checker: SyntheticDotChecker): void {
this.isSyntheticDotActive = checker;
}
/** Set the tree-sitter manager for cache invalidation */
setTreeSitter(treeSitter: TreeSitterManager, workspaceIndex?: WorkspaceIndex): void {
this.treeSitter = treeSitter;
this.workspaceIndex = workspaceIndex ?? null;
}
/**
* Touch a URI in the LRU — moves it to the end (most-recently-used position).
* If the map exceeds maxTracked, evicts the oldest entry and sends didClose.
*/
private touchAndEvict(uri: string): void {
const doc = this.tracked.get(uri);
if (doc) {
// Move to end by deleting and re-inserting
this.tracked.delete(uri);
this.tracked.set(uri, doc);
}
// Evict oldest if over capacity
while (this.tracked.size > this.maxTracked) {
const oldest = this.tracked.entries().next();
if (oldest.done) break;
const [evictUri, evictDoc] = oldest.value;
this.tracked.delete(evictUri);
// Send didClose to the appropriate LSP server
const client = this.manager.getRunningClient(evictDoc.languageId);
if (client) {
client.didClose(evictUri);
}
}
}
/**
* Handle a file being read — sends didOpen if not yet tracked.
* Called from tool_result handler for the `read` tool.
*/
async handleFileRead(filePath: string): Promise<void> {
const absPath = this.manager.resolvePath(filePath);
const uri = this.manager.getFileUri(absPath);
// Already tracked? Just touch it for LRU freshness.
if (this.tracked.has(uri)) {
this.touchAndEvict(uri);
return;
}
const languageId = this.manager.getLanguageId(absPath);
if (!languageId) return;
// Only sync if we have a client already running for this language (don't start one just for a read)
const client = this.manager.getRunningClient(languageId);
if (!client) return;
try {
const content = await readFile(absPath, "utf-8");
const doc: TrackedDocument = { uri, languageId, version: 1 };
this.tracked.set(uri, doc);
client.didOpen(uri, languageId, doc.version, content);
this.touchAndEvict(uri);
} catch {
// File might not exist or be unreadable — ignore
}
}
/**
* Handle a file being written/edited — sends didOpen or didChange.
* Called from tool_result handler for `write` and `edit` tools.
*/
async handleFileWrite(filePath: string): Promise<void> {
const absPath = this.manager.resolvePath(filePath);
const uri = this.manager.getFileUri(absPath);
const languageId = this.manager.getLanguageId(absPath);
// Invalidate tree-sitter cache and re-index
if (this.treeSitter) {
this.treeSitter.invalidate(absPath);
if (this.workspaceIndex) {
// Re-index in the background (don't block the write)
this.workspaceIndex.indexFile(absPath).catch(() => {});
}
}
if (!languageId) return;
// If a synthetic dot operation is in progress for this URI, defer the
// didChange to avoid version conflicts. The completions tool will revert
// the document to the correct content when it's done.
if (this.isSyntheticDotActive(uri)) {
// Schedule a retry after the synthetic dot window (200ms should be enough
// for the 100ms settle delay + completion request + revert)
setTimeout(() => {
// Re-check: if still active, skip — another retry would be needed
if (!this.isSyntheticDotActive(uri)) {
this.handleFileWrite(filePath).catch(() => {});
}
}, 200);
return;
}
// Get client (start server lazily if configured)
const client = await this.manager.getClientForFile(absPath).catch(() => null);
if (!client) return;
try {
const content = await readFile(absPath, "utf-8");
const existing = this.tracked.get(uri);
if (existing) {
// Already open — send didChange with incremented version
existing.version++;
client.didChange(uri, existing.version, content);
} else {
// First time — send didOpen
const doc: TrackedDocument = { uri, languageId, version: 1 };
this.tracked.set(uri, doc);
client.didOpen(uri, languageId, doc.version, content);
}
this.touchAndEvict(uri);
} catch {
// File might not exist or be unreadable — ignore
}
}
/**
* Get the current tracked version for a URI, or null if not tracked.
* Used by tools that need to send temporary didChange notifications
* while keeping versions in sync.
*/
getTrackedVersion(uri: string): number | null {
const doc = this.tracked.get(uri);
return doc ? doc.version : null;
}
/**
* Update the tracked version for a URI after external didChange calls.
* This keeps FileSync in sync when other code (e.g., completions tool)
* sends didChange notifications directly to the LSP client.
*/
setTrackedVersion(uri: string, version: number): void {
const doc = this.tracked.get(uri);
if (doc) {
doc.version = version;
}
}
/** Get the number of tracked documents */
get trackedCount(): number {
return this.tracked.size;
}
}

View File

@@ -0,0 +1,607 @@
/**
* pi-lsp-extension — Pi coding agent extension for LSP integration.
*
* Exposes Language Server Protocol capabilities as tools the LLM can call:
* - lsp_diagnostics: compilation errors and warnings
* - lsp_hover: type info and docs at a position
* - lsp_definition: go to definition
* - lsp_references: find all references
* - lsp_symbols: file/workspace symbol search
* - lsp_rename: preview rename refactoring
* - lsp_completions: code completion suggestions at a position
* - lsp_code_actions: quick fixes, refactorings, and source actions
*
* Position-based tools (hover, definition, references, rename, code_actions)
* accept an optional `query` parameter as an alternative to line/character,
* resolving a symbol name to its position automatically.
*
* Usage:
* 1. npm install in this directory
* 2. Add to pi via settings.json extensions, or: pi -e ./src/index.ts
* 3. LSP servers start lazily when you first use a tool on a file
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import {
isReadToolResult,
isWriteToolResult,
isEditToolResult,
} from "@earendil-works/pi-coding-agent";
import { DiagnosticSeverity, type Diagnostic } from "vscode-languageserver-protocol";
import { LspManager, type ServerConfig, type LspManagerCallbacks } from "./lsp-manager.js";
import { FileSync } from "./file-sync.js";
import { TreeSitterManager } from "./tree-sitter/parser-manager.js";
import { WorkspaceIndex } from "./tree-sitter/workspace-index.js";
import { type WorkspaceProvider, DefaultWorkspaceProvider } from "./workspace-provider.js";
import { createDiagnosticsTool } from "./tools/diagnostics.js";
import { createHoverTool } from "./tools/hover.js";
import { createDefinitionTool } from "./tools/definition.js";
import { createReferencesTool } from "./tools/references.js";
import { createSymbolsTool } from "./tools/symbols.js";
import { createRenameTool } from "./tools/rename.js";
import { createCodeOverviewTool } from "./tools/code-overview.js";
import { createCompletionsTool } from "./tools/completions.js";
import { createCodeSearchTool } from "./tools/code-search.js";
import { createCodeRewriteTool } from "./tools/code-rewrite.js";
import { createCodeActionsTool } from "./tools/code-actions.js";
import { syntheticDotLocks } from "./tools/completions.js";
import { relative } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DIAGNOSTIC_SETTLE_DELAY_MS } from "./shared/timing.js";
/**
* Project-level LSP config — loaded from `.pi-lsp.json` in the workspace root.
*
* Example:
* ```json
* {
* "autoStart": ["java", "typescript"],
* "lombokJar": "env/Lombok-1.18.x/runtime/lib/lombok-1.18.42.jar",
* "servers": {
* "python": { "command": "pylsp", "args": [] }
* }
* }
* ```
*/
interface ProjectLspConfig {
/** Languages to start eagerly on session_start (e.g. ["java", "typescript"]) */
autoStart?: string[];
/** Path to Lombok jar (absolute or relative to project root). "auto" to auto-detect. */
lombokJar?: string;
/** Custom server configs keyed by language ID */
servers?: Record<string, { command: string; args?: string[]; env?: Record<string, string>; initializationOptions?: Record<string, unknown>; settings?: Record<string, unknown> }>;
/**
* Auto-inject LSP error diagnostics into write/edit tool results.
* Set to false to disable, or provide an array of language IDs to enable selectively.
* Default: true (all languages).
*
* Examples:
* true — inject for all languages
* false — never inject
* ["typescript"] — only inject for TypeScript files
*/
autoInjectDiagnostics?: boolean | string[];
}
/** Load .pi-lsp.json from a directory. Returns null if not found or invalid. */
function loadProjectConfig(dir: string): ProjectLspConfig | null {
const configPath = join(dir, ".pi-lsp.json");
try {
if (!existsSync(configPath)) return null;
const raw = readFileSync(configPath, "utf-8");
const parsed = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
return parsed as ProjectLspConfig;
} catch {
return null;
}
}
export default function lspExtension(pi: ExtensionAPI) {
// Prevent EPIPE errors from LSP server exits from crashing the host process.
// When an LSP server exits unexpectedly, in-flight writes to its stdin pipe
// can produce EPIPE errors that escape all connection-level error handlers.
const origListeners = process.listeners("uncaughtException");
process.on("uncaughtException", (err: any) => {
if (err?.code === "EPIPE") return; // swallow — LSP server exited, harmless
// Re-throw for other handlers
for (const listener of origListeners) (listener as any)(err);
if (origListeners.length === 0) {
console.error("[LSP] Uncaught exception:", err);
}
});
let manager: LspManager | null = null;
let fileSync: FileSync | null = null;
let treeSitter: TreeSitterManager | null = null;
let workspaceIndex: WorkspaceIndex | null = null;
let pendingProvider: WorkspaceProvider | null = null;
// Store latest ctx for lifecycle callbacks (updated on each event)
let latestCtx: any = null;
// Project config — loaded on session_start, used by auto-injection guard
let projectConfig: ProjectLspConfig | null = null;
/**
* Run `fn` with the currently captured ctx, swallowing stale-ctx errors.
*
* Lifecycle callbacks from LspManager (server ready, workspace setup, etc.)
* and `pi.events` listeners can fire AFTER the session is replaced or reloaded,
* at which point the captured ctx has been invalidated and any property access
* throws via ExtensionRunner.assertActive(). We can't use optional chaining to
* dodge it because the `ui` / `theme` getters themselves run assertActive().
*/
const withLatestCtx = (fn: (ctx: any) => void) => {
const ctx = latestCtx;
if (!ctx) return;
try {
fn(ctx);
} catch (err: any) {
if (typeof err?.message === "string" && err.message.includes("stale after session")) {
// Session was replaced/reloaded — drop the stale ctx and move on.
latestCtx = null;
return;
}
throw err;
}
};
// Listen for external workspace providers (e.g. bemol extension).
// Also check if one was already registered before we loaded (load order varies).
// Store as pendingProvider so it's available when the manager is created later.
const applyProvider = (data: unknown) => {
const provider = data as WorkspaceProvider;
pendingProvider = provider;
if (manager) {
manager.setWorkspaceProvider(provider);
}
// Update status if we have a UI context
const statusText = provider.getStatusText();
if (!statusText) return;
withLatestCtx((ctx) => {
if (!ctx.ui?.theme) return;
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${statusText}`));
});
};
pi.events.on("lsp:register-workspace-provider", applyProvider);
// Check for provider registered before our listener existed
const existing = (pi.events as any)["lsp:workspace-provider"];
if (existing) applyProvider(existing);
/** Build lifecycle callbacks that update UI status */
const setLspStatus = (color: string, text: string) => {
withLatestCtx((ctx) => {
if (!ctx.ui?.theme) return;
ctx.ui.setStatus("lsp", ctx.ui.theme.fg(color, text));
});
};
const makeCallbacks = (): LspManagerCallbacks => ({
onWorkspaceSetupStart: () => {
setLspStatus("warning", "LSP: workspace setup...");
},
onWorkspaceSetupEnd: (success: boolean, duration: number) => {
const secs = (duration / 1000).toFixed(1);
if (success) {
setLspStatus("accent", `LSP: workspace ready (${secs}s)`);
} else {
setLspStatus("warning", `LSP: workspace setup failed (${secs}s)`);
}
},
onServerStart: (languageId: string, command: string) => {
setLspStatus("warning", `LSP: starting ${languageId} (${command})...`);
},
onServerReady: (languageId: string) => {
setLspStatus("accent", `LSP: ${languageId} ready`);
},
onServerError: (languageId: string, _error: string) => {
setLspStatus("error", `LSP: ${languageId} failed`);
},
onServerCrash: (languageId: string, restarting: boolean, attempt: number) => {
if (restarting) {
setLspStatus("warning", `LSP: restarting ${languageId}... (attempt ${attempt}/3)`);
} else {
setLspStatus("error", `LSP: ${languageId} crashed — auto-restart exhausted`);
}
},
});
// Create manager eagerly so tools can reference it, but servers start lazily
const getManager = (): LspManager => {
if (!manager) {
manager = new LspManager(process.cwd(), undefined, makeCallbacks(), undefined, pendingProvider ?? undefined);
fileSync = new FileSync(manager);
fileSync.setSyntheticDotChecker((uri) => syntheticDotLocks.has(uri));
treeSitter = new TreeSitterManager();
workspaceIndex = new WorkspaceIndex(process.cwd(), treeSitter);
fileSync.setTreeSitter(treeSitter, workspaceIndex);
}
return manager;
};
const getFileSync = (): FileSync => {
if (!fileSync) {
getManager(); // ensures fileSync is created
}
return fileSync!;
};
const getTreeSitter = (): TreeSitterManager => {
if (!treeSitter) {
getManager(); // ensures treeSitter is created
}
return treeSitter!;
};
const getWorkspaceIndex = (): WorkspaceIndex => {
if (!workspaceIndex) {
getManager(); // ensures workspaceIndex is created
}
return workspaceIndex!;
};
// Initialize manager (uses cwd at session start time)
pi.on("session_start", async (_event, ctx) => {
latestCtx = ctx;
// If manager was already created eagerly (e.g. by a tool before session_start),
// shut it down so we can re-create with the correct ctx.cwd.
if (manager) {
await manager.shutdownAll().catch(() => {});
if (treeSitter) treeSitter.shutdown();
}
manager = new LspManager(ctx.cwd, undefined, makeCallbacks(), undefined, pendingProvider ?? undefined);
fileSync = new FileSync(manager);
fileSync.setSyntheticDotChecker((uri) => syntheticDotLocks.has(uri));
treeSitter = new TreeSitterManager();
workspaceIndex = new WorkspaceIndex(ctx.cwd, treeSitter);
fileSync.setTreeSitter(treeSitter, workspaceIndex);
// Initialize tree-sitter in the background (don't block session start)
treeSitter.init().catch((err) => {
console.error(`[pi-lsp-extension] tree-sitter WASM init failed: ${err?.message ?? err}`);
});
// Show workspace provider status
const wsProvider = manager.workspace;
const statusText = wsProvider.getStatusText();
if (statusText) {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${statusText}`));
} else {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("dim", "LSP: idle"));
}
// Load project config and apply settings
projectConfig = loadProjectConfig(ctx.cwd);
if (projectConfig) {
// Apply custom server configs
if (projectConfig.servers) {
for (const [lang, serverConf] of Object.entries(projectConfig.servers)) {
manager.setServerConfig(lang, {
command: serverConf.command,
args: serverConf.args ?? [],
env: serverConf.env,
initializationOptions: serverConf.initializationOptions,
settings: serverConf.settings,
});
}
}
// Set Lombok jar path (explicit path or "auto" for auto-detection)
if (projectConfig.lombokJar) {
if (projectConfig.lombokJar !== "auto") {
manager.setLombokJar(projectConfig.lombokJar);
}
// "auto" is the default behavior — findLombokJar() already auto-detects.
// Setting it explicitly just confirms the user wants Lombok support.
}
// Auto-start configured languages in the background
if (projectConfig.autoStart && projectConfig.autoStart.length > 0) {
const langs = projectConfig.autoStart;
const lombokNote = langs.includes("java") && manager.getLombokJar()
? ` (lombok: ${manager.getLombokJar()?.split("/").pop()})` : "";
setLspStatus("warning", `LSP: auto-starting ${langs.join(", ")}${lombokNote}...`);
manager.startEagerly(langs);
}
}
});
// Register all LSP tools
// Tools call getManager() lazily so they work even if session_start hasn't fired
const managerProxy = new Proxy({} as LspManager, {
get(_target, prop) {
return (getManager() as any)[prop];
},
});
const treeSitterProxy = new Proxy({} as TreeSitterManager, {
get(_target, prop) {
return (getTreeSitter() as any)[prop];
},
});
const workspaceIndexProxy = new Proxy({} as WorkspaceIndex, {
get(_target, prop) {
return (getWorkspaceIndex() as any)[prop];
},
});
pi.registerTool(createDiagnosticsTool(managerProxy, treeSitterProxy));
pi.registerTool(createHoverTool(managerProxy, treeSitterProxy));
pi.registerTool(createDefinitionTool(managerProxy, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createReferencesTool(managerProxy, treeSitterProxy));
pi.registerTool(createSymbolsTool(managerProxy, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createRenameTool(managerProxy, treeSitterProxy));
pi.registerTool(createCodeActionsTool(managerProxy, treeSitterProxy));
pi.registerTool(createCompletionsTool(managerProxy, {
getTrackedVersion: (uri) => getFileSync().getTrackedVersion(uri),
setTrackedVersion: (uri, v) => getFileSync().setTrackedVersion(uri, v),
isSyntheticDotActive: (uri) => syntheticDotLocks.has(uri),
}, treeSitterProxy));
const getRootDir = () => manager?.resolvePath(".") ?? process.cwd();
pi.registerTool(createCodeOverviewTool(getRootDir, treeSitterProxy, workspaceIndexProxy));
pi.registerTool(createCodeSearchTool(getRootDir, treeSitterProxy));
pi.registerTool(createCodeRewriteTool(getRootDir, treeSitterProxy, {
onFileModified: (filePath: string) => {
getFileSync().handleFileWrite(filePath).catch(() => {});
},
}));
pi.registerTool(createCodeActionsTool(managerProxy, treeSitterProxy));
// File sync: track file reads/writes/edits
// After writes/edits, append file-scoped error diagnostics to the tool result
pi.on("tool_result", async (event) => {
const sync = getFileSync();
try {
if (isReadToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileRead(path);
}
if (isWriteToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileWrite(path);
}
if (isEditToolResult(event) && !event.isError) {
const path = (event.input as any)?.path;
if (path) await sync.handleFileWrite(path);
}
} catch {
// File sync errors are non-fatal
}
// Auto-append diagnostics for the changed file (write/edit only)
if ((isWriteToolResult(event) || isEditToolResult(event)) && !event.isError && manager) {
const path = (event.input as any)?.path;
if (!path) return;
const languageId = manager.getLanguageId(path);
if (!languageId) return;
// Check autoInjectDiagnostics config
const inject = projectConfig?.autoInjectDiagnostics;
if (inject === false) return;
if (Array.isArray(inject) && !inject.includes(languageId)) return;
const client = manager.getRunningClient(languageId);
if (!client) return;
// Wait briefly for the LSP to publish updated diagnostics
await new Promise((r) => setTimeout(r, DIAGNOSTIC_SETTLE_DELAY_MS));
const uri = manager.getFileUri(path);
const diagnostics = client.getDiagnostics(uri);
const errors = diagnostics.filter((d) => d.severity === DiagnosticSeverity.Error);
if (errors.length === 0) return;
// Build a compact summary — just errors, max 10 lines
const relPath = relative(manager.resolvePath("."), manager.resolvePath(path));
const lines = errors.slice(0, 10).map((d) => {
const line = d.range.start.line + 1;
const col = d.range.start.character + 1;
const source = d.source ? ` [${d.source}]` : "";
return `${relPath}:${line}:${col} error: ${d.message}${source}`;
});
if (errors.length > 10) {
lines.push(`... and ${errors.length - 10} more error(s)`);
}
const summary = `\n\n⚠ LSP: ${errors.length} error(s) in ${relPath}:\n${lines.join("\n")}`;
return {
content: [
...event.content,
{ type: "text" as const, text: summary },
],
};
}
});
// Update status after tool execution ends
pi.on("tool_execution_end", async (_event, ctx) => {
latestCtx = ctx;
if (!manager) return;
const statuses = manager.getStatus();
const running = statuses.filter((s) => s.running);
if (running.length === 0) {
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("dim", "LSP: idle"));
} else {
const totalDiags = running.reduce((n, s) => n + s.diagnosticsCount, 0);
const langs = running.map((s) => s.languageId).join(", ");
let status = `LSP: ${langs}`;
if (totalDiags > 0) {
status += ` (${totalDiags} diagnostic${totalDiags !== 1 ? "s" : ""})`;
}
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", status));
}
});
// /lsp command — show server status
pi.registerCommand("lsp", {
description: "Show LSP server status",
handler: async (_args, ctx) => {
if (!manager) {
ctx.ui.notify("LSP manager not initialized", "warning");
return;
}
const statuses = manager.getStatus();
if (statuses.length === 0) {
ctx.ui.notify("No LSP servers configured", "info");
return;
}
const lines = statuses.map((s) => {
const icon = s.running ? "🟢" : "⚪";
const diags =
s.diagnosticsCount > 0 ? ` (${s.diagnosticsCount} diagnostics)` : "";
const shared = s.shared ? " [shared]" : "";
return `${icon} ${s.languageId}: ${s.command}${diags}${shared}`;
});
ctx.ui.notify(lines.join("\n"), "info");
},
});
// /lsp-restart command — restart a specific language server
pi.registerCommand("lsp-restart", {
description: "Restart an LSP server: /lsp-restart <language> (e.g. java, typescript)",
handler: async (args, ctx) => {
if (!manager) {
ctx.ui.notify("LSP manager not initialized", "warning");
return;
}
const languageId = args?.trim().toLowerCase();
if (!languageId) {
// Show running servers and usage
const statuses = manager.getStatus().filter((s) => s.running);
if (statuses.length === 0) {
ctx.ui.notify("No LSP servers are running.\n\nUsage: /lsp-restart <language>", "info");
} else {
const langs = statuses.map((s) => s.languageId).join(", ");
ctx.ui.notify(
`Running servers: ${langs}\n\nUsage: /lsp-restart <language>\nExample: /lsp-restart java`,
"info"
);
}
return;
}
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("warning", `LSP: restarting ${languageId}...`));
ctx.ui.notify(`Restarting ${languageId} server (kills daemon if shared)...`, "info");
try {
await manager.restartServer(languageId);
const lombokJar = languageId === "java" ? manager.getLombokJar() : null;
const lombokNote = lombokJar ? `\nLombok: ${lombokJar}` : "";
ctx.ui.notify(`${languageId} server restarted successfully.${lombokNote}`, "info");
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("accent", `LSP: ${languageId} ready`));
} catch (err: any) {
ctx.ui.notify(`Failed to restart ${languageId}: ${err.message}`, "error");
ctx.ui.setStatus("lsp", ctx.ui.theme.fg("error", `LSP: ${languageId} restart failed`));
}
},
});
// /lsp-config command — add or override server configuration
pi.registerCommand("lsp-config", {
description:
"Configure an LSP server: /lsp-config <language> <command> [args...]",
handler: async (args, ctx) => {
if (!args?.trim()) {
ctx.ui.notify(
"Usage: /lsp-config <language> <command> [args...]\nExample: /lsp-config python pylsp",
"info"
);
return;
}
const parts = args.trim().split(/\s+/);
if (parts.length < 2) {
ctx.ui.notify(
"Usage: /lsp-config <language> <command> [args...]",
"warning"
);
return;
}
const [languageId, command, ...serverArgs] = parts;
const config: ServerConfig = { command, args: serverArgs };
getManager().setServerConfig(languageId, config);
ctx.ui.notify(
`Configured LSP for ${languageId}: ${command} ${serverArgs.join(" ")}`,
"info"
);
},
});
// /lsp-lombok command — set Lombok jar path for Java
pi.registerCommand("lsp-lombok", {
description:
"Set Lombok jar path for Java: /lsp-lombok <path-to-lombok.jar>",
handler: async (args, ctx) => {
const mgr = getManager();
if (!args?.trim()) {
const current = mgr.getLombokJar();
if (current) {
ctx.ui.notify(`Lombok jar: ${current}`, "info");
} else {
ctx.ui.notify(
"No Lombok jar configured or detected.\n\n" +
"Usage: /lsp-lombok <path-to-lombok.jar>\n" +
"Or set LOMBOK_JAR environment variable.\n\n" +
"Download from: https://projectlombok.org/download",
"info"
);
}
return;
}
const jarPath = args.trim();
const { existsSync } = await import("node:fs");
const { resolve } = await import("node:path");
const resolved = resolve(ctx.cwd, jarPath);
if (!existsSync(resolved)) {
ctx.ui.notify(`File not found: ${resolved}`, "error");
return;
}
if (!resolved.endsWith(".jar")) {
ctx.ui.notify(`Warning: ${resolved} doesn't end in .jar — setting anyway`, "warning");
}
mgr.setLombokJar(resolved);
ctx.ui.notify(`Lombok jar set: ${resolved}`, "info");
},
});
// Clean shutdown (includes workspace provider, all LSP servers, and tree-sitter)
pi.on("session_shutdown", async () => {
// Drop the captured ctx immediately — once shutdown fires, any late
// LSP manager callback that reaches setLspStatus/applyProvider would
// otherwise hit an invalidated ctx and throw an uncaught exception.
latestCtx = null;
if (manager) {
await manager.shutdownAll();
manager = null;
fileSync = null;
}
if (treeSitter) {
treeSitter.shutdown();
treeSitter = null;
}
workspaceIndex = null;
});
}

View File

@@ -0,0 +1,443 @@
/**
* LSP Client — JSON-RPC client for LSP servers.
*
* Supports two modes:
* - **Direct**: spawns LSP server as child process (stdio)
* - **Socket**: connects to an LSP daemon via Unix domain socket
*
* Uses vscode-jsonrpc (bundled with vscode-languageserver-protocol) for
* JSON-RPC message framing and transport.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { connect as netConnect, type Socket } from "node:net";
import { pathToFileURL } from "node:url";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
SocketMessageReader,
SocketMessageWriter,
type MessageConnection,
} from "vscode-languageserver-protocol/node.js";
import type {
InitializeParams,
InitializeResult,
ServerCapabilities,
Diagnostic,
PublishDiagnosticsParams,
} from "vscode-languageserver-protocol";
export interface LspClientOptions {
/** Command to start the LSP server */
command: string;
/** Arguments for the command */
args: string[];
/** Root directory of the workspace */
rootDir: string;
/** Language ID this server handles */
languageId: string;
/** Extra environment variables */
env?: Record<string, string>;
/** Additional workspace folders (e.g. for multi-package workspaces) */
workspaceFolders?: { uri: string; name: string }[];
/** Connect to existing daemon socket instead of spawning a new process */
socketPath?: string;
/** LSP initializationOptions (e.g. jdtls settings for Lombok) */
initializationOptions?: Record<string, unknown>;
/** Settings returned by workspace/configuration handler (keyed by section, e.g. { intelephense: {...} }) */
settings?: Record<string, unknown>;
/** Called when the server exits unexpectedly (not from user-initiated shutdown) */
onUnexpectedExit?: (code: number | null) => void;
}
export class LspClient {
private process: ChildProcess | null = null;
private socket: Socket | null = null;
private connection: MessageConnection | null = null;
private _serverCapabilities: ServerCapabilities | null = null;
private _diagnostics: Map<string, Diagnostic[]> = new Map();
private _initialized = false;
private _disposed = false;
/** True if connected to a daemon socket (server init handled by daemon) */
private _isDaemonClient = false;
readonly languageId: string;
readonly command: string;
readonly rootDir: string;
constructor(private options: LspClientOptions) {
this.languageId = options.languageId;
this.command = options.command;
this.rootDir = options.rootDir;
}
get initialized(): boolean {
return this._initialized;
}
get disposed(): boolean {
return this._disposed;
}
get serverCapabilities(): ServerCapabilities | null {
return this._serverCapabilities;
}
/** Get cached diagnostics for a URI */
getDiagnostics(uri: string): Diagnostic[] {
return this._diagnostics.get(uri) ?? [];
}
/** Get all cached diagnostics */
getAllDiagnostics(): Map<string, Diagnostic[]> {
return new Map(this._diagnostics);
}
/** Start the LSP server and perform the initialize handshake */
async start(): Promise<void> {
if (this._initialized || this._disposed) return;
if (this.options.socketPath) {
await this.connectToSocket(this.options.socketPath);
} else {
await this.spawnDirect();
}
}
/** Register shared connection handlers (diagnostics, workspace/configuration, errors) */
private registerConnectionHandlers(): void {
if (!this.connection) return;
// Listen for published diagnostics
this.connection.onNotification(
"textDocument/publishDiagnostics",
(params: PublishDiagnosticsParams) => {
this._diagnostics.set(params.uri, params.diagnostics);
}
);
// Handle workspace/configuration requests from the server.
// Servers like Intelephense request their settings via this method.
// Return settings from options if configured, otherwise empty defaults.
this.connection.onRequest(
"workspace/configuration",
(params: { items: { section?: string }[] }) => {
const settings = this.options.settings;
return params.items.map((item) => {
if (item.section && settings && item.section in settings) {
return settings[item.section];
}
return {};
});
}
);
// Handle connection-level errors to prevent unhandled exceptions
this.connection.onError(([err]) => {
console.error(`[LSP ${this.languageId}] Connection error: ${err.message}`);
});
this.connection.onClose(() => {
if (!this._disposed) {
this._initialized = false;
}
});
}
/** Connect to an existing LSP daemon via Unix socket (no init handshake needed) */
private async connectToSocket(socketPath: string): Promise<void> {
this._isDaemonClient = true;
return new Promise((resolve, reject) => {
let settled = false;
const settle = (fn: () => void) => {
if (!settled) { settled = true; fn(); }
};
const socket = netConnect(socketPath, () => {
this.socket = socket;
const reader = new SocketMessageReader(socket);
const writer = new SocketMessageWriter(socket);
this.connection = createMessageConnection(reader, writer);
this.registerConnectionHandlers();
this.connection.listen();
this._initialized = true;
settle(() => resolve());
});
socket.on("error", (err) => {
if (!this._initialized) {
settle(() => reject(new Error(`Failed to connect to LSP daemon: ${err.message}`)));
} else {
this._initialized = false;
}
});
socket.on("close", () => {
if (!this._disposed) {
this._initialized = false;
this.options.onUnexpectedExit?.(null);
}
});
// Timeout
setTimeout(() => {
if (!settled) {
socket.destroy();
settle(() => reject(new Error("Timeout connecting to LSP daemon socket")));
}
}, 10_000);
});
}
/** Spawn LSP server directly as child process with stdio */
private async spawnDirect(): Promise<void> {
const env = { ...process.env, ...this.options.env };
this.process = spawn(this.options.command, this.options.args, {
stdio: ["pipe", "pipe", "pipe"],
env,
cwd: this.rootDir,
});
if (!this.process.stdout || !this.process.stdin) {
throw new Error(`Failed to spawn LSP server: ${this.options.command}`);
}
// Wait for the process to successfully spawn before setting up the connection.
// spawn() is async — ENOENT and other errors arrive on the 'error' event.
// If we don't wait, we'll try to write to a destroyed stdin and crash.
await new Promise<void>((resolve, reject) => {
const onSpawn = () => { cleanup(); resolve(); };
const onError = (err: Error) => {
cleanup();
reject(new Error(`Failed to spawn LSP server "${this.options.command}": ${err.message}`));
};
const cleanup = () => {
this.process?.removeListener("spawn", onSpawn);
this.process?.removeListener("error", onError);
};
this.process!.on("spawn", onSpawn);
this.process!.on("error", onError);
});
// Discard stderr to prevent blocking
this.process.stderr?.resume();
// Patch stdin.write to silently drop writes when the stream is destroyed.
// StreamMessageWriter wraps stdin and calls write() which returns a Promise.
// If the stream is destroyed (process exited), write() throws ERR_STREAM_DESTROYED
// inside the Promise constructor, creating a rejection that propagates through
// the writer's semaphore and becomes unhandled (notifications are fire-and-forget).
// No amount of error handlers on the stream or connection can catch this.
const stdin = this.process.stdin!;
const originalWrite = stdin.write;
stdin.write = function (this: typeof stdin, ...args: any[]): boolean {
if (this.destroyed || this.writableEnded || this.writableFinished) {
// Call the callback (last arg) so the Promise resolves instead of rejecting
const cb = args[args.length - 1];
if (typeof cb === "function") process.nextTick(cb);
return false;
}
try {
return originalWrite.apply(this, args as any);
} catch (err: any) {
// Catch EPIPE synchronously — process exited between our check and the write
if (err?.code === "EPIPE" || err?.code === "ERR_STREAM_DESTROYED") {
const cb = args[args.length - 1];
if (typeof cb === "function") process.nextTick(cb);
return false;
}
throw err;
}
} as any;
// Catch EPIPE on the stdin stream itself to prevent unhandled error events
stdin.on("error", (err: any) => {
if (err?.code === "EPIPE") return; // expected when server exits
console.error(`[LSP ${this.languageId}] stdin error: ${err.message}`);
});
this.process.on("error", (err) => {
console.error(`[LSP ${this.languageId}] Process error: ${err.message}`);
this._initialized = false;
this.disposeConnection();
});
this.process.on("exit", (code) => {
if (!this._disposed) {
console.error(`[LSP ${this.languageId}] Server exited with code ${code}`);
this._initialized = false;
this.disposeConnection();
this.options.onUnexpectedExit?.(code);
}
});
const reader = new StreamMessageReader(this.process.stdout);
const writer = new StreamMessageWriter(this.process.stdin);
this.connection = createMessageConnection(reader, writer);
this.registerConnectionHandlers();
this.connection.listen();
// Initialize handshake
const rootUri = pathToFileURL(this.rootDir).toString();
const defaultFolder = { uri: rootUri, name: this.rootDir.split("/").pop() ?? "workspace" };
// Use provided workspace folders or fall back to single root
const workspaceFolders = this.options.workspaceFolders && this.options.workspaceFolders.length > 0
? this.options.workspaceFolders
: [defaultFolder];
const initParams: InitializeParams = {
processId: process.pid,
capabilities: {
textDocument: {
synchronization: {
didSave: true,
dynamicRegistration: false,
},
hover: {
contentFormat: ["plaintext", "markdown"],
},
definition: {},
references: {},
documentSymbol: {
hierarchicalDocumentSymbolSupport: true,
},
rename: {
prepareSupport: false,
},
publishDiagnostics: {
relatedInformation: true,
},
completion: {
completionItem: {
snippetSupport: false,
},
},
},
workspace: {
workspaceFolders: true,
symbol: {},
configuration: true,
},
},
rootUri,
workspaceFolders,
...(this.options.initializationOptions
? { initializationOptions: this.options.initializationOptions }
: {}),
};
const result: InitializeResult = await this.connection.sendRequest(
"initialize",
initParams
);
this._serverCapabilities = result.capabilities;
// Send initialized notification
this.connection.sendNotification("initialized", {});
this._initialized = true;
}
/** Safely dispose the connection without throwing */
private disposeConnection(): void {
try {
if (this.connection) {
this.connection.dispose();
}
} catch {
// Already disposed or stream destroyed — ignore
}
this.connection = null;
}
/** Send a request to the LSP server */
async sendRequest<R>(method: string, params: unknown): Promise<R> {
if (!this.connection || !this._initialized) {
throw new Error(`LSP ${this.languageId} not initialized`);
}
return this.connection.sendRequest(method, params) as Promise<R>;
}
/** Send a notification to the LSP server */
sendNotification(method: string, params: unknown): void {
if (!this.connection || !this._initialized) return;
this.connection.sendNotification(method, params);
}
/** Notify server of a newly opened document */
didOpen(uri: string, languageId: string, version: number, text: string): void {
this.sendNotification("textDocument/didOpen", {
textDocument: { uri, languageId, version, text },
});
}
/** Notify server of a document change (full content sync) */
didChange(uri: string, version: number, text: string): void {
this.sendNotification("textDocument/didChange", {
textDocument: { uri, version },
contentChanges: [{ text }],
});
}
/** Notify server of a closed document */
didClose(uri: string): void {
this.sendNotification("textDocument/didClose", {
textDocument: { uri },
});
}
/** Gracefully shut down or disconnect from the server */
async shutdown(): Promise<void> {
if (this._disposed) return;
this._disposed = true;
this._initialized = false;
if (this._isDaemonClient) {
// Socket client: just disconnect — daemon keeps the server alive
this.disposeConnection();
if (this.socket) {
this.socket.destroy();
}
this.socket = null;
return;
}
// Direct mode: shut down the server we own
try {
if (this.connection) {
// Race shutdown request against a timeout. Catch the request separately
// so if the timeout wins, the abandoned sendRequest rejection doesn't
// become an unhandled promise rejection.
const shutdownReq = this.connection.sendRequest("shutdown").catch(() => {});
await Promise.race([
shutdownReq,
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
try { this.connection.sendNotification("exit"); } catch {}
}
} catch {
// Server may already be dead
}
this.disposeConnection();
if (this.process) {
this.process.kill("SIGTERM");
// Force kill after 2s
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill("SIGKILL");
}
}, 2000);
}
this.process = null;
}
}

View File

@@ -0,0 +1,22 @@
/**
* Daemon launcher — loads lsp-daemon.ts via jiti.
*
* Usage: node lsp-daemon-launcher.cjs <jitiPath> <daemonScript> <socketPath> <command> [args...]
*
* This tiny JS file exists because the daemon runs as a detached process
* and can't rely on --import hooks. It uses the jiti path resolved at
* spawn time from the parent's module context.
*/
const [,, jitiPath, daemonScript, ...rest] = process.argv;
if (!jitiPath || !daemonScript) {
console.error("Usage: node lsp-daemon-launcher.cjs <jitiPath> <daemonScript> <socketPath> <command> [args...]");
process.exit(1);
}
// Override argv so the daemon sees: [node, daemonScript, socketPath, command, ...args]
process.argv = [process.argv[0], daemonScript, ...rest];
const { createJiti } = require(jitiPath);
const jiti = createJiti(daemonScript);
jiti(daemonScript);

View File

@@ -0,0 +1,537 @@
#!/usr/bin/env node
/**
* LSP Daemon — persistent LSP server proxy over Unix domain socket.
*
* Spawns an LSP server (stdio) and multiplexes connections from multiple
* pi sessions via a Unix socket. This avoids running duplicate heavy
* LSP servers (jdtls, pyright, etc.) across sessions in the same workspace.
*
* Protocol: clients connect to the socket and speak JSON-RPC using
* LSP Content-Length framing — same as talking to an LSP server directly.
*
* Lifecycle:
* - First session spawns daemon (detached)
* - Daemon spawns LSP server, listens on socket
* - Sessions connect/disconnect freely
* - After last client disconnects, daemon waits 5 min then exits
*
* Usage: node lsp-daemon.js <socketPath> <command> [args...]
* Env: LSP_ROOT_DIR, LSP_LANGUAGE_ID, LSP_WORKSPACE_FOLDERS (JSON)
*/
import { createServer, type Server, type Socket } from "node:net";
import { spawn, type ChildProcess } from "node:child_process";
import { unlinkSync, existsSync, writeFileSync, mkdirSync, appendFileSync } from "node:fs";
import { dirname } from "node:path";
import { pathToFileURL } from "node:url";
// ── LSP Message Framing ────────────────────────────────────────────────────
interface JsonRpcMessage {
jsonrpc: "2.0";
id?: number | string;
method?: string;
params?: unknown;
result?: unknown;
error?: unknown;
}
/**
* Incremental parser for LSP Content-Length framed messages.
* Accumulates data and emits complete JSON-RPC messages.
*/
class MessageParser {
private buffer = Buffer.alloc(0);
private contentLength = -1;
private headerComplete = false;
private onMessage: (msg: JsonRpcMessage) => void;
constructor(onMessage: (msg: JsonRpcMessage) => void) {
this.onMessage = onMessage;
}
feed(data: Buffer): void {
this.buffer = Buffer.concat([this.buffer, data]);
this.parse();
}
private parse(): void {
while (true) {
if (!this.headerComplete) {
const headerEnd = this.buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) return; // incomplete header
const header = this.buffer.subarray(0, headerEnd).toString("ascii");
const match = header.match(/Content-Length:\s*(\d+)/i);
if (!match) {
// Skip malformed header — advance past it
this.buffer = this.buffer.subarray(headerEnd + 4);
continue;
}
this.contentLength = parseInt(match[1], 10);
this.headerComplete = true;
this.buffer = this.buffer.subarray(headerEnd + 4);
}
if (this.buffer.length < this.contentLength) return; // incomplete body
const body = this.buffer.subarray(0, this.contentLength).toString("utf-8");
this.buffer = this.buffer.subarray(this.contentLength);
this.contentLength = -1;
this.headerComplete = false;
try {
const msg = JSON.parse(body) as JsonRpcMessage;
this.onMessage(msg);
} catch {
// Skip malformed JSON
}
}
}
}
/** Encode a JSON-RPC message with Content-Length header */
function encodeMessage(msg: JsonRpcMessage): Buffer {
const body = JSON.stringify(msg);
const bodyBytes = Buffer.byteLength(body, "utf-8");
const header = `Content-Length: ${bodyBytes}\r\n\r\n`;
return Buffer.concat([Buffer.from(header, "ascii"), Buffer.from(body, "utf-8")]);
}
// ── Client Tracking ────────────────────────────────────────────────────────
interface ClientConnection {
id: number;
socket: Socket;
parser: MessageParser;
}
// ── Main Daemon ────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const socketPath = args[0];
const lspCommand = args[1];
const lspArgs = args.slice(2);
const rootDir = process.env.LSP_ROOT_DIR || process.cwd();
const languageId = process.env.LSP_LANGUAGE_ID || "unknown";
const workspaceFoldersJson = process.env.LSP_WORKSPACE_FOLDERS;
const initializationOptionsJson = process.env.LSP_INITIALIZATION_OPTIONS;
const settingsJson = process.env.LSP_SETTINGS;
if (!socketPath || !lspCommand) {
console.error("Usage: lsp-daemon.js <socketPath> <command> [args...]");
process.exit(1);
}
let nextClientId = 1;
let nextDaemonRequestId = 1;
const clients = new Map<number, ClientConnection>();
/** Map daemon request ID → { clientId, originalId } for client-initiated requests */
const pendingRequests = new Map<number | string, { clientId: number; originalId: number | string }>();
/** Map server request ID → clientId for server-initiated requests forwarded to a client */
const pendingServerRequests = new Map<number | string, number>();
/** Parsed settings for workspace/configuration responses */
let settings: Record<string, unknown> | undefined;
let shutdownTimer: ReturnType<typeof setTimeout> | null = null;
let server: Server;
let lspProcess: ChildProcess;
let lspParser: MessageParser;
let lspInitialized = false;
/** The request ID used for the initialize handshake (set by initializeLsp) */
let initRequestId: number | null = null;
/** Resolve/reject callbacks for the initialize promise */
let initResolve: (() => void) | null = null;
let initReject: ((err: Error) => void) | null = null;
// ── Spawn LSP Server ───────────────────────────────────────────────────────
function spawnLspServer(): ChildProcess {
const child = spawn(lspCommand, lspArgs, {
stdio: ["pipe", "pipe", "pipe"],
cwd: rootDir,
env: process.env,
});
child.stderr?.resume(); // prevent blocking
child.on("error", (err) => {
log(`LSP server error: ${err.message}`);
shutdown(1);
});
child.on("exit", (code) => {
log(`LSP server exited with code ${code}`);
shutdown(0);
});
return child;
}
// ── Message Routing ────────────────────────────────────────────────────────
/** Forward a message from a client to the LSP server, rewriting request IDs */
function clientToServer(clientId: number, msg: JsonRpcMessage): void {
if (!lspProcess.stdin?.writable) return;
if (msg.id !== undefined && msg.method) {
// Request from client — rewrite ID to track routing
const daemonId = nextDaemonRequestId++;
pendingRequests.set(daemonId, { clientId, originalId: msg.id });
const rewritten = { ...msg, id: daemonId };
lspProcess.stdin.write(encodeMessage(rewritten));
} else if (msg.id !== undefined && !msg.method) {
// Response from client to a server-initiated request — relay back to LSP server
if (pendingServerRequests.has(msg.id)) {
pendingServerRequests.delete(msg.id);
lspProcess.stdin.write(encodeMessage(msg));
}
} else {
// Notification from client — forward as-is
lspProcess.stdin.write(encodeMessage(msg));
}
}
/**
* Route a message from the LSP server.
* Before initialization: looks for the init response. All other messages are dropped
* (no clients can be connected before init completes anyway).
* After initialization: routes to connected clients.
*/
function handleServerMessage(msg: JsonRpcMessage): void {
if (!lspInitialized) {
// During init, only the initialize response matters
if (initRequestId !== null && msg.id === initRequestId && !msg.method) {
lspInitialized = true;
initRequestId = null;
// Send initialized notification
lspProcess.stdin!.write(encodeMessage({
jsonrpc: "2.0",
method: "initialized",
params: {},
}));
log(`LSP server initialized (${languageId})`);
initResolve?.();
initResolve = null;
initReject = null;
}
// Other pre-init messages (e.g. window/logMessage) are ignored —
// no clients are connected yet anyway
return;
}
serverToClients(msg);
}
/** Route a message from the LSP server to the appropriate client(s) */
function serverToClients(msg: JsonRpcMessage): void {
if (msg.id !== undefined && !msg.method) {
// Response — route to the client that sent the request
const pending = pendingRequests.get(msg.id);
if (pending) {
pendingRequests.delete(msg.id);
const client = clients.get(pending.clientId);
if (client && !client.socket.destroyed) {
const rewritten = { ...msg, id: pending.originalId };
client.socket.write(encodeMessage(rewritten));
}
}
} else if (msg.id !== undefined && msg.method) {
// Server-initiated request (e.g. workspace/configuration) — handle locally or forward to a client
handleServerRequest(msg);
} else if (msg.method && msg.id === undefined) {
// Notification from server — broadcast to ALL clients
const encoded = encodeMessage(msg);
for (const client of clients.values()) {
if (!client.socket.destroyed) {
client.socket.write(encoded);
}
}
}
}
/**
* Handle a server-initiated request.
* Some requests (workspace/configuration) can be answered locally by the daemon.
* Others are forwarded to the first connected client and the response is relayed back.
*/
function handleServerRequest(msg: JsonRpcMessage): void {
if (msg.method === "workspace/configuration") {
// Answer locally — daemon has the settings from LSP_SETTINGS env
const params = msg.params as { items: { section?: string }[] } | undefined;
const items = params?.items ?? [];
const result = items.map((item) => {
if (item.section && settings && item.section in settings) {
return settings[item.section];
}
return {};
});
const response: JsonRpcMessage = { jsonrpc: "2.0", id: msg.id!, result };
lspProcess.stdin!.write(encodeMessage(response));
return;
}
// For other server-initiated requests, forward to first connected client
// and relay the response back to the server
const firstClient = getFirstConnectedClient();
if (firstClient) {
pendingServerRequests.set(msg.id!, firstClient.id);
firstClient.socket.write(encodeMessage(msg));
} else {
// No clients connected — respond with error
const errorResponse: JsonRpcMessage = {
jsonrpc: "2.0",
id: msg.id!,
error: { code: -32001, message: "No clients connected to handle request" },
};
lspProcess.stdin!.write(encodeMessage(errorResponse));
}
}
/** Get first non-destroyed connected client */
function getFirstConnectedClient(): ClientConnection | undefined {
for (const client of clients.values()) {
if (!client.socket.destroyed) return client;
}
return undefined;
}
// ── Initialize LSP Server ──────────────────────────────────────────────────
async function initializeLsp(): Promise<void> {
return new Promise((resolve, reject) => {
const rootUri = pathToFileURL(rootDir).toString();
let workspaceFolders = [{ uri: rootUri, name: rootDir.split("/").pop() ?? "workspace" }];
if (workspaceFoldersJson) {
try {
const parsed = JSON.parse(workspaceFoldersJson);
if (Array.isArray(parsed) && parsed.length > 0) {
workspaceFolders = parsed;
}
} catch { /* use default */ }
}
let initializationOptions: Record<string, unknown> | undefined;
if (initializationOptionsJson) {
try {
initializationOptions = JSON.parse(initializationOptionsJson);
} catch { /* ignore */ }
}
const initParams = {
processId: process.pid,
capabilities: {
textDocument: {
synchronization: { didSave: true, dynamicRegistration: false },
hover: { contentFormat: ["plaintext", "markdown"] },
definition: {},
references: {},
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
rename: { prepareSupport: false },
publishDiagnostics: { relatedInformation: true },
completion: { completionItem: { snippetSupport: false } },
},
workspace: { workspaceFolders: true, symbol: {}, configuration: true },
},
rootUri,
workspaceFolders,
...(initializationOptions ? { initializationOptions } : {}),
};
// Store resolve/reject so handleServerMessage can settle the promise
initResolve = resolve;
initReject = reject;
// Set the init request ID so handleServerMessage knows which response to watch for
initRequestId = nextDaemonRequestId++;
const initRequest: JsonRpcMessage = {
jsonrpc: "2.0",
id: initRequestId,
method: "initialize",
params: initParams,
};
lspProcess.stdin!.write(encodeMessage(initRequest));
// Timeout
setTimeout(() => {
if (!lspInitialized) {
initResolve = null;
initReject = null;
reject(new Error("LSP initialize timed out after 5 minutes"));
}
}, 5 * 60_000);
});
}
// ── Socket Server ──────────────────────────────────────────────────────────
function startSocketServer(): Server {
// Ensure socket directory exists
mkdirSync(dirname(socketPath), { recursive: true });
// Clean up stale socket
if (existsSync(socketPath)) {
try { unlinkSync(socketPath); } catch { /* ignore */ }
}
const srv = createServer((socket) => {
const clientId = nextClientId++;
log(`Client ${clientId} connected`);
// Cancel shutdown timer
if (shutdownTimer) {
clearTimeout(shutdownTimer);
shutdownTimer = null;
}
const parser = new MessageParser((msg) => {
clientToServer(clientId, msg);
});
const conn: ClientConnection = { id: clientId, socket, parser };
clients.set(clientId, conn);
socket.on("data", (data) => parser.feed(Buffer.from(data)));
socket.on("close", () => {
log(`Client ${clientId} disconnected`);
clients.delete(clientId);
// Clean up pending requests for this client
for (const [reqId, pending] of pendingRequests) {
if (pending.clientId === clientId) {
pendingRequests.delete(reqId);
}
}
// Start shutdown timer if no clients remain
if (clients.size === 0) {
log("No clients remaining, will shut down in 5 minutes");
shutdownTimer = setTimeout(() => shutdown(0), 5 * 60 * 1000);
}
});
socket.on("error", (err) => {
log(`Client ${clientId} error: ${err.message}`);
clients.delete(clientId);
});
});
srv.listen(socketPath, () => {
log(`Listening on ${socketPath}`);
// Write PID file
const pidPath = socketPath.replace(/\.sock$/, ".pid");
writeFileSync(pidPath, String(process.pid));
});
srv.on("error", (err) => {
log(`Socket server error: ${err.message}`);
shutdown(1);
});
return srv;
}
// ── Lifecycle ──────────────────────────────────────────────────────────────
function log(msg: string): void {
const ts = new Date().toISOString();
const logPath = socketPath.replace(/\.sock$/, ".log");
try {
appendFileSync(logPath, `[${ts}] ${msg}\n`);
} catch { /* ignore */ }
}
function shutdown(code: number): void {
log("Shutting down daemon");
if (shutdownTimer) clearTimeout(shutdownTimer);
// Close all client connections
for (const client of clients.values()) {
client.socket.destroy();
}
clients.clear();
// Shut down LSP server
if (lspProcess && !lspProcess.killed) {
try {
lspProcess.stdin?.write(encodeMessage({
jsonrpc: "2.0",
id: nextDaemonRequestId++,
method: "shutdown",
params: null,
}));
setTimeout(() => {
lspProcess.stdin?.write(encodeMessage({
jsonrpc: "2.0",
method: "exit",
params: null,
}));
setTimeout(() => {
if (!lspProcess.killed) lspProcess.kill("SIGTERM");
}, 1000);
}, 2000);
} catch {
lspProcess.kill("SIGTERM");
}
}
// Clean up socket and PID files
try { unlinkSync(socketPath); } catch { /* ignore */ }
try { unlinkSync(socketPath.replace(/\.sock$/, ".pid")); } catch { /* ignore */ }
if (server) server.close();
setTimeout(() => process.exit(code), 3000);
}
process.on("SIGTERM", () => shutdown(0));
process.on("SIGINT", () => shutdown(0));
process.on("uncaughtException", (err) => {
log(`Uncaught exception: ${err.message}\n${err.stack ?? ""}`);
shutdown(1);
});
process.on("unhandledRejection", (reason) => {
log(`Unhandled rejection: ${reason}`);
shutdown(1);
});
// ── Start ──────────────────────────────────────────────────────────────────
async function main() {
log(`Starting daemon: ${lspCommand} ${lspArgs.join(" ")} (${languageId})`);
// Parse settings for workspace/configuration responses
if (settingsJson) {
try {
settings = JSON.parse(settingsJson);
} catch (e) { log(`Warning: failed to parse LSP_SETTINGS: ${e}`); }
}
lspProcess = spawnLspServer();
// Single parser for all server messages — routes via handleServerMessage
// which checks lspInitialized to decide whether to look for init response
// or forward to clients. No parser swapping needed.
lspParser = new MessageParser(handleServerMessage);
lspProcess.stdout!.on("data", (data) => lspParser.feed(data));
server = startSocketServer();
try {
await initializeLsp();
} catch (err: any) {
log(`Failed to initialize LSP server: ${err.message}`);
shutdown(1);
}
}
main().catch((err) => {
log(`Daemon fatal error: ${err.message}`);
process.exit(1);
});

View File

@@ -0,0 +1,707 @@
/**
* LSP Manager — manages multiple LSP server instances, one per language.
*
* Lazily starts servers on first use. Auto-detects language from file extension.
* Uses a WorkspaceProvider for workspace detection, multi-root folders, and daemon state.
*/
import { resolve, join, dirname } from "node:path";
import { pathToFileURL } from "node:url";
import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
import { spawn as spawnChild } from "node:child_process";
import { LspClient } from "./lsp-client.js";
import { type WorkspaceProvider, DefaultWorkspaceProvider } from "./workspace-provider.js";
import { getLanguageIdFromPath } from "./shared/language-map.js";
import { DAEMON_SOCKET_READY_DELAY_MS, DAEMON_RETRY_INTERVAL_MS, DAEMON_MAX_RETRIES } from "./shared/timing.js";
export interface ServerConfig {
command: string;
args: string[];
env?: Record<string, string>;
/** LSP initializationOptions passed during the initialize handshake */
initializationOptions?: Record<string, unknown>;
/** Settings returned by workspace/configuration handler (keyed by section) */
settings?: Record<string, unknown>;
}
/** Lifecycle callbacks for UI notifications */
export interface LspManagerCallbacks {
onWorkspaceSetupStart?: () => void;
onWorkspaceSetupEnd?: (success: boolean, duration: number) => void;
onServerStart?: (languageId: string, command: string) => void;
onServerReady?: (languageId: string) => void;
onServerError?: (languageId: string, error: string) => void;
onServerCrash?: (languageId: string, restarting: boolean, attempt: number) => void;
}
/** Default server configurations for common languages */
const DEFAULT_SERVERS: Record<string, ServerConfig> = {
typescript: { command: "typescript-language-server", args: ["--stdio"] },
javascript: { command: "typescript-language-server", args: ["--stdio"] },
typescriptreact: { command: "typescript-language-server", args: ["--stdio"] },
javascriptreact: { command: "typescript-language-server", args: ["--stdio"] },
rust: { command: "rust-analyzer", args: [] },
python: { command: "pyright-langserver", args: ["--stdio"] },
go: { command: "gopls", args: ["serve"] },
java: { command: "jdtls", args: [] },
};
// File extension → language ID mapping is in shared/language-map.ts
export interface ServerStatus {
languageId: string;
command: string;
running: boolean;
diagnosticsCount: number;
/** True if using a shared daemon (vs direct spawn) */
shared: boolean;
}
export class LspManager {
private clients: Map<string, LspClient> = new Map();
private serverConfigs: Map<string, ServerConfig>;
private rootDir: string;
private startingServers: Map<string, Promise<LspClient>> = new Map();
private _workspace: WorkspaceProvider;
private _workspaceReady = false;
private _workspaceReadying: Promise<boolean> | null = null;
private _callbacks: LspManagerCallbacks;
private _sessionId: string;
private _lombokJarPath: string | null = null;
private _shuttingDown = false;
private _restartAttempts: Map<string, number> = new Map();
private _restartBackoff: Map<string, number> = new Map();
private static readonly MAX_RESTART_ATTEMPTS = 3;
private static readonly INITIAL_BACKOFF_MS = 1000;
private static readonly MAX_BACKOFF_MS = 30000;
constructor(rootDir: string, customConfigs?: Record<string, ServerConfig>, callbacks?: LspManagerCallbacks, sessionId?: string, workspace?: WorkspaceProvider) {
this.rootDir = resolve(rootDir);
this.serverConfigs = new Map(Object.entries({
...DEFAULT_SERVERS,
...customConfigs,
}));
this._workspace = workspace ?? new DefaultWorkspaceProvider();
this._callbacks = callbacks ?? {};
this._sessionId = sessionId ?? `${process.pid}-${Date.now()}`;
}
/** Get the workspace provider */
get workspace(): WorkspaceProvider {
return this._workspace;
}
/** Replace the workspace provider (e.g. when an external extension registers one) */
setWorkspaceProvider(provider: WorkspaceProvider): void {
this._workspace = provider;
this._workspaceReady = false;
this._workspaceReadying = null;
}
/** Update or add a server configuration */
setServerConfig(languageId: string, config: ServerConfig): void {
this.serverConfigs.set(languageId, config);
}
/** Set an explicit path to a Lombok jar for Java/jdtls support */
setLombokJar(jarPath: string): void {
this._lombokJarPath = resolve(this.rootDir, jarPath);
}
/** Get the currently configured Lombok jar path (if any) */
getLombokJar(): string | null {
return this.findLombokJar();
}
/**
* Auto-detect Lombok jar.
* Searches: explicit path, LOMBOK_JAR env var, workspace root env/ directories.
*/
private findLombokJar(): string | null {
// 1. Explicit path set via setLombokJar()
if (this._lombokJarPath) {
if (existsSync(this._lombokJarPath)) return this._lombokJarPath;
}
// 2. LOMBOK_JAR environment variable
const envJar = process.env.LOMBOK_JAR;
if (envJar) {
const resolved = resolve(this.rootDir, envJar);
if (existsSync(resolved)) return resolved;
}
// 3. Auto-detect from workspace root (e.g. Brazil workspace env/ directory)
// Search both workspace root and cwd in case they differ
const searchRoots = [this.rootDir];
const wsRoot = this._workspace.workspaceRoot;
if (wsRoot && wsRoot !== this.rootDir) {
searchRoots.unshift(wsRoot);
}
for (const root of searchRoots) {
const envDir = join(root, "env");
if (!existsSync(envDir)) continue;
// env/Lombok-{version}/runtime/lib/lombok-*.jar
try {
const lombokDirs = readdirSync(envDir).filter(d => d.startsWith("Lombok-"));
for (const dir of lombokDirs) {
const libDir = join(envDir, dir, "runtime", "lib");
if (existsSync(libDir)) {
const jars = readdirSync(libDir).filter(f => f.startsWith("lombok-") && f.endsWith(".jar"));
if (jars.length > 0) return join(libDir, jars[0]);
}
}
} catch { /* ignore */ }
// env/gradle-cache-2/org/projectlombok/lombok/{version}/lombok-{version}.jar
const gradleLombok = join(envDir, "gradle-cache-2", "org", "projectlombok", "lombok");
if (existsSync(gradleLombok)) {
try {
const versions = readdirSync(gradleLombok);
for (const ver of versions) {
const jarPath = join(gradleLombok, ver, `lombok-${ver}.jar`);
if (existsSync(jarPath)) return jarPath;
}
} catch { /* ignore */ }
}
}
return null;
}
/** Build initializationOptions for Java (jdtls) with Lombok support */
private getJavaInitializationOptions(): Record<string, unknown> | undefined {
const lombokJar = this.findLombokJar();
if (!lombokJar) return undefined;
// jdtls expects flat dotted keys in settings, not nested objects
return {
settings: {
"java.jdt.ls.vmargs": `-javaagent:${lombokJar}`,
},
};
}
/** Get all configured languages */
getConfiguredLanguages(): string[] {
return [...this.serverConfigs.keys()];
}
/** Resolve a file path to a language ID */
getLanguageId(filePath: string): string | undefined {
return getLanguageIdFromPath(filePath);
}
/** Get a file URI from a path */
getFileUri(filePath: string): string {
const abs = resolve(this.rootDir, filePath);
return pathToFileURL(abs).toString();
}
/** Resolve an absolute path from potentially relative input */
resolvePath(filePath: string): string {
return resolve(this.rootDir, filePath);
}
/**
* Get the LSP client for a language ONLY if it's already running.
* Does not start a new server. Returns null if no client is active.
*/
getRunningClient(languageId: string): LspClient | null {
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) {
return existing;
}
return null;
}
/**
* Get the LSP client for a file, starting the server if needed.
* Returns null if no server is configured for this file type.
*/
async getClientForFile(filePath: string): Promise<LspClient | null> {
const languageId = this.getLanguageId(filePath);
if (!languageId) return null;
return this.getClientForLanguage(languageId);
}
/**
* Get a user-friendly message about why no client is available for a file.
*/
getUnavailableReason(filePath: string): string {
const languageId = this.getLanguageId(filePath);
if (!languageId) return `No LSP server configured for file type: ${filePath}`;
const waitHint = this.getExpectedStartTime(languageId);
if (this.startingServers.has(languageId)) {
return `LSP server for ${languageId} is starting. ${waitHint} Retry shortly.`;
}
const existing = this.clients.get(languageId);
if (existing && !existing.initialized && !existing.disposed) {
return `LSP server for ${languageId} is initializing. ${waitHint} Retry shortly.`;
}
return `No LSP server available for: ${filePath}. If you just opened this project, call any LSP tool on a file to trigger server startup.`;
}
/** Estimated startup time hint for a language server */
private getExpectedStartTime(languageId: string): string {
switch (languageId) {
case "java":
return "This typically takes 1-5 minutes for Java (project indexing).";
case "rust":
return "This typically takes 30s-2min for Rust (cargo metadata + indexing).";
case "typescript":
case "javascript":
case "typescriptreact":
case "javascriptreact":
return "This typically takes 10-30s for TypeScript/JavaScript.";
case "python":
return "This typically takes 10-30s for Python.";
case "go":
return "This typically takes 10-30s for Go.";
default:
return "This may take a few seconds to a minute.";
}
}
/**
* Get the LSP client for a language, starting the server if needed.
* In Brazil workspaces, connects to a shared daemon (or spawns one).
* Returns null if no server is configured for this language.
*/
async getClientForLanguage(languageId: string): Promise<LspClient | null> {
// Already running?
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) {
return existing;
}
// Already starting? Don't block — return null so tools can show "starting" message
const starting = this.startingServers.get(languageId);
if (starting) return null;
const config = this.serverConfigs.get(languageId);
if (!config) return null;
// Kick off server start in the background — don't await
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
// Fire-and-forget: clean up on completion or failure
startPromise.catch((err) => {
this.startingServers.delete(languageId);
const message = `Failed to start LSP server for ${languageId}: ${err.message}`;
this._callbacks.onServerError?.(languageId, message);
});
return null; // Server not ready yet
}
/**
* Check if a server is currently starting up for a language.
*/
isServerStarting(languageId: string): boolean {
return this.startingServers.has(languageId);
}
/**
* Eagerly start LSP servers for the given languages in the background.
* Unlike getClientForLanguage (which returns null immediately), this method
* is fire-and-forget — intended for session_start auto-start.
*/
startEagerly(languageIds: string[]): void {
for (const languageId of languageIds) {
// Skip if already running or starting
const existing = this.clients.get(languageId);
if (existing && existing.initialized && !existing.disposed) continue;
if (this.startingServers.has(languageId)) continue;
const config = this.serverConfigs.get(languageId);
if (!config) continue;
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
startPromise.catch((err) => {
this.startingServers.delete(languageId);
this._callbacks.onServerError?.(languageId, `Auto-start failed: ${err.message}`);
});
}
}
/**
* Ensure workspace provider is ready (one-time per session).
* Deduplicates concurrent calls.
*/
private async ensureWorkspace(): Promise<void> {
if (this._workspaceReady) return;
if (this._workspaceReadying) {
await this._workspaceReadying;
return;
}
this._callbacks.onWorkspaceSetupStart?.();
const start = Date.now();
this._workspaceReadying = this._workspace.ensureReady(this._sessionId);
try {
const success = await this._workspaceReadying;
this._callbacks.onWorkspaceSetupEnd?.(success, Date.now() - start);
} finally {
this._workspaceReady = true;
this._workspaceReadying = null;
}
}
private async startServer(languageId: string, config: ServerConfig): Promise<LspClient> {
// Ensure workspace provider is ready (one-time setup)
await this.ensureWorkspace();
const stateDir = this._workspace.stateDir;
const folders = this._workspace.getWorkspaceFolders();
const workspaceFolders = folders.length > 0 ? folders : undefined;
// Build language-specific initializationOptions (e.g. Lombok for Java)
// Use config-level initializationOptions if provided, otherwise fall back to language-specific defaults
const initializationOptions = config.initializationOptions
?? (languageId === "java" ? this.getJavaInitializationOptions() : undefined);
// For Java with Lombok, inject --jvm-arg so jdtls launches with -javaagent
let effectiveArgs = config.args;
if (languageId === "java") {
const lombokJar = this.findLombokJar();
if (lombokJar) {
effectiveArgs = [`--jvm-arg=-javaagent:${lombokJar}`, ...config.args];
}
}
// Callback for auto-restart on unexpected exit
const onUnexpectedExit = (code: number | null) => this.handleUnexpectedExit(languageId, code);
// Try connecting to an existing daemon socket first
const socketPath = this.getSocketPath(languageId);
if (socketPath && this.isDaemonAlive(languageId)) {
this._callbacks.onServerStart?.(languageId, `${config.command} (shared)`);
try {
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
socketPath,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch {
// Daemon may be stale — fall through to spawn new one
}
}
// No existing daemon — spawn one (or start direct if no state directory)
this._callbacks.onServerStart?.(languageId, config.command);
if (stateDir) {
// Spawn daemon and connect via socket
try {
await this.spawnDaemon(languageId, config, effectiveArgs, workspaceFolders, initializationOptions);
// Small delay to let daemon start listening
await new Promise((r) => setTimeout(r, DAEMON_SOCKET_READY_DELAY_MS));
const daemonSocket = this.getSocketPath(languageId)!;
// Retry connection (daemon may still be initializing jdtls which can take minutes)
let lastErr: Error | null = null;
for (let attempt = 0; attempt < DAEMON_MAX_RETRIES; attempt++) {
try {
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
socketPath: daemonSocket,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch (err: any) {
lastErr = err;
// Check if daemon is still alive before retrying
if (!this.isDaemonAlive(languageId)) {
throw new Error(`Daemon for ${languageId} died during startup: ${err.message}`);
}
await new Promise((r) => setTimeout(r, DAEMON_RETRY_INTERVAL_MS));
}
}
throw lastErr ?? new Error("Failed to connect to daemon");
} catch (err: any) {
// Fall back to direct mode — log the daemon failure
this._callbacks.onServerError?.(languageId, `Daemon mode failed, falling back to direct: ${err.message}`);
this.startingServers.delete(languageId);
// Don't throw — try direct mode below
}
}
// Direct mode (no state directory for daemon, or daemon failed)
const client = new LspClient({
command: config.command,
args: effectiveArgs,
rootDir: this.rootDir,
languageId,
env: config.env,
workspaceFolders,
initializationOptions,
settings: config.settings,
onUnexpectedExit,
});
try {
await client.start();
this.clients.set(languageId, client);
this.startingServers.delete(languageId);
this._callbacks.onServerReady?.(languageId);
this._restartAttempts.delete(languageId);
this._restartBackoff.delete(languageId);
this.triggerPostInit(languageId, client);
return client;
} catch (err: any) {
this.startingServers.delete(languageId);
const message = `Failed to start LSP server for ${languageId} (${config.command}): ${err.message}`;
this._callbacks.onServerError?.(languageId, message);
throw new Error(message);
}
}
/**
* Post-initialization hook for language-specific setup.
* For Java/jdtls: triggers a workspace build so diagnostics refresh
* after Lombok annotation processing completes.
*/
private triggerPostInit(languageId: string, client: LspClient): void {
if (languageId === "java") {
// java/buildWorkspace forces jdtls to rebuild and re-publish diagnostics.
// The boolean parameter means "full build" (true) vs incremental (false).
client.sendRequest("java/buildWorkspace", true).catch(() => {
// Not critical — diagnostics will just be stale until a file changes
});
}
}
/** Get the socket path for a language's daemon */
private getSocketPath(languageId: string): string | null {
const stateDir = this._workspace.stateDir;
if (!stateDir) return null;
return join(stateDir, "sockets", `lsp-${languageId}.sock`);
}
/** Check if a daemon is alive for this language */
private isDaemonAlive(languageId: string): boolean {
const stateDir = this._workspace.stateDir;
if (!stateDir) return false;
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
try {
if (!existsSync(pidPath)) return false;
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
if (isNaN(pid)) return false;
process.kill(pid, 0); // throws if not alive
return true;
} catch {
return false;
}
}
/** Spawn an LSP daemon as a detached background process */
private async spawnDaemon(
languageId: string,
config: ServerConfig,
effectiveArgs: string[],
workspaceFolders?: { uri: string; name: string }[],
initializationOptions?: Record<string, unknown>,
): Promise<void> {
const socketPath = this.getSocketPath(languageId)!;
const daemonScript = new URL("./lsp-daemon.ts", import.meta.url).pathname;
const launcherScript = new URL("./lsp-daemon-launcher.cjs", import.meta.url).pathname;
// Resolve jiti from the running process's module context
let jitiPath: string;
try {
jitiPath = require.resolve("jiti");
} catch {
try {
jitiPath = require.resolve("@mariozechner/jiti");
} catch {
throw new Error("Cannot resolve jiti for daemon spawn — jiti not found in module path");
}
}
const env: Record<string, string> = {
...process.env as Record<string, string>,
...(config.env ?? {}),
LSP_ROOT_DIR: this.rootDir,
LSP_LANGUAGE_ID: languageId,
};
if (workspaceFolders && workspaceFolders.length > 0) {
env.LSP_WORKSPACE_FOLDERS = JSON.stringify(workspaceFolders);
}
if (initializationOptions) {
env.LSP_INITIALIZATION_OPTIONS = JSON.stringify(initializationOptions);
}
if (config.settings) {
env.LSP_SETTINGS = JSON.stringify(config.settings);
}
const child = spawnChild(
process.execPath, // node
[launcherScript, jitiPath, daemonScript, socketPath, config.command, ...effectiveArgs],
{
cwd: this.rootDir,
env,
detached: true,
stdio: "ignore",
},
);
child.unref(); // let daemon outlive this process
}
/** Get status of all configured/running servers */
getStatus(): ServerStatus[] {
const statuses: ServerStatus[] = [];
for (const [languageId, config] of this.serverConfigs) {
const client = this.clients.get(languageId);
let diagnosticsCount = 0;
if (client) {
for (const diags of client.getAllDiagnostics().values()) {
diagnosticsCount += diags.length;
}
}
const daemonAlive = this.isDaemonAlive(languageId);
statuses.push({
languageId,
command: config.command,
running: client?.initialized === true && !client.disposed,
diagnosticsCount,
shared: daemonAlive,
});
}
return statuses;
}
/**
* Handle an unexpected server exit. Attempts auto-restart with exponential backoff.
* Gives up after MAX_RESTART_ATTEMPTS failures per language per session.
*/
private handleUnexpectedExit(languageId: string, code: number | null): void {
if (this._shuttingDown) return;
// Clean up the dead client
this.clients.delete(languageId);
this.startingServers.delete(languageId);
const attempts = this._restartAttempts.get(languageId) ?? 0;
if (attempts >= LspManager.MAX_RESTART_ATTEMPTS) {
this._callbacks.onServerError?.(languageId, `LSP server for ${languageId} crashed ${attempts} times — giving up auto-restart`);
this._callbacks.onServerCrash?.(languageId, false, attempts);
return;
}
const backoff = this._restartBackoff.get(languageId) ?? LspManager.INITIAL_BACKOFF_MS;
this._restartAttempts.set(languageId, attempts + 1);
this._restartBackoff.set(languageId, Math.min(backoff * 2, LspManager.MAX_BACKOFF_MS));
this._callbacks.onServerCrash?.(languageId, true, attempts + 1);
setTimeout(() => {
if (this._shuttingDown) return;
const config = this.serverConfigs.get(languageId);
if (!config) return;
const startPromise = this.startServer(languageId, config);
this.startingServers.set(languageId, startPromise);
startPromise.catch((err) => {
this.startingServers.delete(languageId);
this._callbacks.onServerError?.(languageId, `Auto-restart failed for ${languageId}: ${err.message}`);
// Trigger another restart attempt (recursive backoff)
this.handleUnexpectedExit(languageId, null);
});
}, backoff);
}
/** Shut down all clients (disconnect from daemons, kill direct servers) */
async shutdownAll(): Promise<void> {
this._workspace.shutdown();
this._shuttingDown = true;
const shutdowns = [...this.clients.values()].map((client) =>
client.shutdown().catch(() => {})
);
await Promise.all(shutdowns);
this.clients.clear();
this.startingServers.clear();
}
/**
* Restart a specific language server. Shuts down the existing client
* (and kills the daemon if shared), then starts a fresh server.
* Returns once the new server is initialized, or throws on failure.
*/
async restartServer(languageId: string): Promise<void> {
// Shut down existing client
const existing = this.clients.get(languageId);
if (existing) {
await existing.shutdown().catch(() => {});
this.clients.delete(languageId);
}
// Kill the daemon if one is running (so we get a fresh server with new config)
this.killDaemon(languageId);
// Wait for pending starts to clear
const pending = this.startingServers.get(languageId);
if (pending) {
await pending.catch(() => {});
this.startingServers.delete(languageId);
}
// Start fresh
const config = this.serverConfigs.get(languageId);
if (!config) throw new Error(`No server configured for ${languageId}`);
await this.startServer(languageId, config);
}
/** Kill a running daemon for a language (if any) */
private killDaemon(languageId: string): void {
const stateDir = this._workspace.stateDir;
if (!stateDir) return;
const pidPath = join(stateDir, "sockets", `lsp-${languageId}.pid`);
try {
if (!existsSync(pidPath)) return;
const pid = parseInt(readFileSync(pidPath, "utf-8").trim(), 10);
if (isNaN(pid)) return;
process.kill(pid, "SIGTERM");
// Clean up socket and pid files
const socketPath = join(stateDir, "sockets", `lsp-${languageId}.sock`);
try { unlinkSync(socketPath); } catch {}
try { unlinkSync(pidPath); } catch {}
} catch {
// Process may already be dead
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* Resolve Provider — determines whether to use LSP or tree-sitter for a file.
*
* LSP takes priority when available. Tree-sitter is the fallback.
*/
import type { LspManager } from "./lsp-manager.js";
import type { TreeSitterManager } from "./tree-sitter/parser-manager.js";
export type ProviderResult =
| { type: "lsp" }
| { type: "tree-sitter"; languageId: string }
| { type: "none"; reason: string };
/**
* Determine the best intelligence provider for a file.
*
* 1. If an LSP server is running and initialized → "lsp"
* 2. If tree-sitter has a grammar for the language → "tree-sitter"
* 3. Otherwise → "none" with a human-readable reason
*/
export function resolveProvider(
filePath: string,
manager: LspManager,
treeSitter: TreeSitterManager | null,
): ProviderResult {
const languageId = manager.getLanguageId(filePath);
if (languageId) {
// Check if LSP is running for this language
const client = manager.getRunningClient(languageId);
if (client) return { type: "lsp" };
// Check if LSP is starting (server will be ready soon)
if (manager.isServerStarting(languageId)) {
// Still prefer tree-sitter for now since LSP isn't ready yet
if (treeSitter?.hasGrammar(languageId)) {
return { type: "tree-sitter", languageId };
}
return { type: "none", reason: `LSP server for ${languageId} is still starting up. Try again in a moment.` };
}
}
// No LSP — try tree-sitter
if (treeSitter) {
const tsLang = treeSitter.getLanguageId(filePath);
if (tsLang && treeSitter.hasGrammar(tsLang)) {
return { type: "tree-sitter", languageId: tsLang };
}
}
// No intelligence available
const ext = filePath.match(/\.[^.]+$/)?.[0] ?? "";
return {
type: "none",
reason: `No code intelligence available for ${ext || "this file type"}. No LSP server is running and no tree-sitter grammar is available.`,
};
}
/**
* Check if an LSP server is available and running for a language.
* Does NOT start a server — just checks if one is already running.
*/
export function hasLspServer(manager: LspManager, languageId: string): boolean {
const client = manager.getRunningClient(languageId);
return client !== null;
}

View File

@@ -0,0 +1,19 @@
/**
* Shared constants — directories to skip, file size limits, etc.
*
* Used across code-overview, workspace-index, and search-engine.
*/
/** Directories to always skip when walking the project tree */
export const SKIP_DIRS = new Set([
"node_modules", ".git", "build", "dist", "target", "out", ".next",
"__pycache__", ".tox", ".venv", "venv", ".mypy_cache", ".pytest_cache",
"vendor", ".gradle", ".idea", ".vscode", ".bemol", "env",
"coverage", ".nyc_output", ".cache",
]);
/** Max file size to parse (500KB) */
export const MAX_FILE_SIZE = 500 * 1024;
/** Max files to index in the initial pass */
export const MAX_INDEX_FILES = 5000;

View File

@@ -0,0 +1,17 @@
/**
* Shared debug logger — lightweight logging for non-fatal errors.
*
* Controlled by the PI_LSP_DEBUG environment variable.
* When enabled, logs to stderr so they're visible but don't interfere
* with JSON-RPC or tool output.
*/
const DEBUG = process.env.PI_LSP_DEBUG === "1" || process.env.PI_LSP_DEBUG === "true";
/** Log a debug message if PI_LSP_DEBUG is enabled */
export function debug(context: string, message: string, error?: unknown): void {
if (!DEBUG) return;
const errMsg = error instanceof Error ? error.message : error ? String(error) : "";
const suffix = errMsg ? `: ${errMsg}` : "";
console.error(`[pi-lsp] ${context}${suffix ? " — " + message + suffix : " — " + message}`);
}

View File

@@ -0,0 +1,29 @@
/**
* Shared formatting utilities for LSP tool results.
*/
import type { Location, LocationLink } from "vscode-languageserver-protocol";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
/** Format an LSP Location as a relative file:line:col string */
export function formatLocation(loc: Location, rootDir: string): string {
try {
const absPath = fileURLToPath(loc.uri);
const relPath = relative(rootDir, absPath);
return `${relPath}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
} catch {
return `${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
}
}
/** Format an LSP LocationLink as a relative file:line:col string */
export function formatLocationLink(link: LocationLink, rootDir: string): string {
try {
const absPath = fileURLToPath(link.targetUri);
const relPath = relative(rootDir, absPath);
return `${relPath}:${link.targetSelectionRange.start.line + 1}:${link.targetSelectionRange.start.character + 1}`;
} catch {
return `${link.targetUri}:${link.targetSelectionRange.start.line + 1}:${link.targetSelectionRange.start.character + 1}`;
}
}

View File

@@ -0,0 +1,51 @@
/**
* Shared language mappings — single source of truth for file extension → language ID.
*
* Used by both LspManager and TreeSitterManager.
*/
/** Map file extensions to LSP language IDs */
export const EXT_TO_LANGUAGE: Record<string, string> = {
".ts": "typescript",
".tsx": "typescriptreact",
".js": "javascript",
".jsx": "javascriptreact",
".mts": "typescript",
".mjs": "javascript",
".cts": "typescript",
".cjs": "javascript",
".rs": "rust",
".py": "python",
".go": "go",
".java": "java",
".c": "c",
".h": "c",
".cpp": "cpp",
".cc": "cpp",
".hpp": "cpp",
".cs": "csharp",
".rb": "ruby",
".kt": "kotlin",
".kts": "kotlin",
".scala": "scala",
".ex": "elixir",
".exs": "elixir",
".lua": "lua",
".sh": "bash",
".bash": "bash",
".zsh": "bash",
".swift": "swift",
".zig": "zig",
".json": "json",
".html": "html",
".htm": "html",
".css": "css",
".vue": "vue",
".php": "php",
};
/** Get the language ID for a file path based on extension */
export function getLanguageIdFromPath(filePath: string): string | undefined {
const ext = filePath.match(/\.[^.]+$/)?.[0]?.toLowerCase();
return ext ? EXT_TO_LANGUAGE[ext] : undefined;
}

View File

@@ -0,0 +1,265 @@
/**
* Shared position resolver — resolves a symbol name to a file position.
*
* Used by position-based tools (hover, definition, references, rename, completions)
* to allow the LLM to pass a symbol name instead of exact line/character.
*/
import type { DocumentSymbol, SymbolInformation } from "vscode-languageserver-protocol";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { extractSymbols, type SymbolInfo } from "../tree-sitter/symbol-extractor.js";
import { getLanguageIdFromPath } from "./language-map.js";
import { readFile } from "node:fs/promises";
export interface ResolvedPosition {
line: number; // 1-indexed (tool convention)
character: number; // 1-indexed
symbolName: string;
source: "lsp" | "tree-sitter";
}
type DocumentSymbolResponse = DocumentSymbol[] | SymbolInformation[] | null;
/**
* Resolve a symbol name to a position in a file.
*
* Priority:
* 1. LSP document symbols (most accurate)
* 2. Tree-sitter symbol extraction (fallback)
*
* Matching priority:
* 1. Exact case-sensitive match
* 2. Case-insensitive exact match
* 3. Substring match (case-insensitive)
* 4. Dot-qualified match (e.g. "MyClass.render" matches "render" inside "MyClass")
*/
export async function resolveSymbolPosition(
filePath: string,
query: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<ResolvedPosition | null> {
// Try LSP document symbols first
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbolResponse>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
if (symbols && symbols.length > 0) {
const match = findInDocumentSymbols(symbols, query);
if (match) return match;
}
} catch { /* fall through to tree-sitter */ }
}
// Try tree-sitter fallback
if (treeSitter) {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const languageId = getLanguageIdFromPath(filePath);
if (languageId) {
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, languageId);
const match = findInSymbolInfos(symbols, query);
if (match) return match;
}
}
} catch { /* fall through */ }
}
return null;
}
/**
* Get top-level symbol names from a file (for error hints).
*/
export async function getSymbolNames(
filePath: string,
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): Promise<string[]> {
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
const uri = manager.getFileUri(filePath);
try {
const symbols = await client.sendRequest<DocumentSymbolResponse>(
"textDocument/documentSymbol",
{ textDocument: { uri } }
);
if (symbols && symbols.length > 0) {
if ("selectionRange" in symbols[0]) {
return (symbols as DocumentSymbol[]).map(s => s.name);
}
return (symbols as SymbolInformation[]).map(s => s.name);
}
} catch { /* fall through */ }
}
if (treeSitter) {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const languageId = getLanguageIdFromPath(filePath);
if (languageId) {
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, languageId);
return symbols.map(s => s.name);
}
}
} catch { /* fall through */ }
}
return [];
}
// --- LSP DocumentSymbol matching ---
function findInDocumentSymbols(
symbols: DocumentSymbol[] | SymbolInformation[],
query: string,
): ResolvedPosition | null {
if (symbols.length === 0) return null;
// Check if these are DocumentSymbol (hierarchical) or SymbolInformation (flat)
if ("selectionRange" in symbols[0]) {
return findInHierarchicalSymbols(symbols as DocumentSymbol[], query);
}
return findInFlatSymbols(symbols as SymbolInformation[], query);
}
interface SymbolCandidate {
name: string;
line: number; // 1-indexed
character: number; // 1-indexed
parent?: string;
}
function findInHierarchicalSymbols(
symbols: DocumentSymbol[],
query: string,
): ResolvedPosition | null {
const candidates = flattenDocumentSymbols(symbols);
return matchCandidates(candidates, query, "lsp");
}
function flattenDocumentSymbols(
symbols: DocumentSymbol[],
parent?: string,
): SymbolCandidate[] {
const result: SymbolCandidate[] = [];
for (const sym of symbols) {
result.push({
name: sym.name,
line: sym.selectionRange.start.line + 1,
character: sym.selectionRange.start.character + 1,
parent,
});
if (sym.children && sym.children.length > 0) {
result.push(...flattenDocumentSymbols(sym.children, sym.name));
}
}
return result;
}
function findInFlatSymbols(
symbols: SymbolInformation[],
query: string,
): ResolvedPosition | null {
const candidates: SymbolCandidate[] = symbols.map(sym => ({
name: sym.name,
line: sym.location.range.start.line + 1,
character: sym.location.range.start.character + 1,
parent: sym.containerName ?? undefined,
}));
return matchCandidates(candidates, query, "lsp");
}
// --- Tree-sitter SymbolInfo matching ---
function findInSymbolInfos(
symbols: SymbolInfo[],
query: string,
): ResolvedPosition | null {
const candidates = flattenSymbolInfos(symbols);
return matchCandidates(candidates, query, "tree-sitter");
}
function flattenSymbolInfos(
symbols: SymbolInfo[],
parent?: string,
): SymbolCandidate[] {
const result: SymbolCandidate[] = [];
for (const sym of symbols) {
result.push({
name: sym.name,
line: sym.line,
character: 1, // tree-sitter symbols don't have column precision for the name
parent,
});
if (sym.children && sym.children.length > 0) {
result.push(...flattenSymbolInfos(sym.children, sym.name));
}
}
return result;
}
// --- Shared matching logic ---
function matchCandidates(
candidates: SymbolCandidate[],
query: string,
source: "lsp" | "tree-sitter",
): ResolvedPosition | null {
// Support dot-qualified queries like "MyClass.render"
const dotIndex = query.lastIndexOf(".");
let parentFilter: string | undefined;
let symbolQuery: string;
if (dotIndex > 0) {
parentFilter = query.slice(0, dotIndex);
symbolQuery = query.slice(dotIndex + 1);
} else {
symbolQuery = query;
}
// If dot-qualified, try to match parent.child first
if (parentFilter) {
const qualified = candidates.filter(
c => c.parent?.toLowerCase() === parentFilter!.toLowerCase()
);
const match = matchByPriority(qualified, symbolQuery, source);
if (match) return match;
}
// Fall back to unqualified match across all candidates
return matchByPriority(candidates, symbolQuery, source);
}
function matchByPriority(
candidates: SymbolCandidate[],
query: string,
source: "lsp" | "tree-sitter",
): ResolvedPosition | null {
const queryLower = query.toLowerCase();
// 1. Exact case-sensitive match
const exact = candidates.find(c => c.name === query);
if (exact) return { line: exact.line, character: exact.character, symbolName: exact.name, source };
// 2. Case-insensitive exact match
const caseInsensitive = candidates.find(c => c.name.toLowerCase() === queryLower);
if (caseInsensitive) return { line: caseInsensitive.line, character: caseInsensitive.character, symbolName: caseInsensitive.name, source };
// 3. Substring match (case-insensitive)
const substring = candidates.find(c => c.name.toLowerCase().includes(queryLower));
if (substring) return { line: substring.line, character: substring.character, symbolName: substring.name, source };
return null;
}

View File

@@ -0,0 +1,18 @@
/**
* Shared timing constants for LSP operations.
*/
/** Delay (ms) to wait for LSP to publish diagnostics after a file change */
export const DIAGNOSTIC_SETTLE_DELAY_MS = 1500;
/** Delay (ms) to wait for a daemon socket to start listening after spawn */
export const DAEMON_SOCKET_READY_DELAY_MS = 500;
/** Interval (ms) between retries when connecting to a daemon */
export const DAEMON_RETRY_INTERVAL_MS = 5000;
/** Maximum number of retries when connecting to a daemon (5 min total at 5s intervals) */
export const DAEMON_MAX_RETRIES = 60;
/** Delay (ms) to let LSP process a synthetic didChange before requesting completions */
export const SYNTHETIC_DOT_SETTLE_DELAY_MS = 100;

View File

@@ -0,0 +1,284 @@
/**
* lsp_code_actions — Get available code actions (quick fixes, refactorings) at a position.
*/
import { Type } from "@sinclair/typebox";
import type {
CodeAction,
Command,
Diagnostic,
TextEdit,
WorkspaceEdit,
} from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
type CodeActionResponse = (CodeAction | Command)[] | null;
const CodeActionsParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
endLine: Type.Optional(Type.Number({ description: "End line for range selection (1-indexed). Defaults to line." })),
endCharacter: Type.Optional(Type.Number({ description: "End column for range selection (1-indexed). Defaults to character." })),
kind: Type.Optional(Type.String({ description: 'Filter by action kind (e.g., "quickfix", "refactor", "source")' })),
});
interface CodeActionsDetails {
count: number;
preferredCount: number;
}
/** Check if a range contains a position (all 0-indexed) */
function rangeContainsPosition(
range: { start: { line: number; character: number }; end: { line: number; character: number } },
line: number,
character: number,
): boolean {
if (line < range.start.line || line > range.end.line) return false;
if (line === range.start.line && character < range.start.character) return false;
if (line === range.end.line && character > range.end.character) return false;
return true;
}
/** Format a workspace edit's changes into readable lines */
function formatEditSummary(edit: WorkspaceEdit, rootDir: string): string[] {
const lines: string[] = [];
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("textDocument" in change && "edits" in change) {
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(change.textDocument.uri)); } catch { relPath = change.textDocument.uri; }
for (const textEdit of (change.edits as TextEdit[]).slice(0, 5)) {
const ln = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
const endLn = textEdit.range.end.line + 1;
const endCol = textEdit.range.end.character + 1;
const newText = textEdit.newText.length > 60
? textEdit.newText.slice(0, 57) + "..."
: textEdit.newText;
if (textEdit.range.start.line === textEdit.range.end.line && textEdit.range.start.character === textEdit.range.end.character) {
lines.push(` ${relPath}:${ln}:${col} insert "${newText.replace(/\n/g, "\\n")}"`);
} else {
lines.push(` ${relPath}:${ln}:${col}-${endLn}:${endCol} → "${newText.replace(/\n/g, "\\n")}"`);
}
}
const remaining = (change.edits as TextEdit[]).length - 5;
if (remaining > 0) lines.push(` ... and ${remaining} more edits in ${relPath}`);
}
}
}
const changes = edit.changes ?? {};
for (const [uri, edits] of Object.entries(changes)) {
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(uri)); } catch { relPath = uri; }
for (const textEdit of edits.slice(0, 5)) {
const ln = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
const endLn = textEdit.range.end.line + 1;
const endCol = textEdit.range.end.character + 1;
const newText = textEdit.newText.length > 60
? textEdit.newText.slice(0, 57) + "..."
: textEdit.newText;
if (textEdit.range.start.line === textEdit.range.end.line && textEdit.range.start.character === textEdit.range.end.character) {
lines.push(` ${relPath}:${ln}:${col} insert "${newText.replace(/\n/g, "\\n")}"`);
} else {
lines.push(` ${relPath}:${ln}:${col}-${endLn}:${endCol} → "${newText.replace(/\n/g, "\\n")}"`);
}
}
const remaining = edits.length - 5;
if (remaining > 0) lines.push(` ... and ${remaining} more edits in ${relPath}`);
}
return lines;
}
/** Check if a response item is a CodeAction (vs a Command) */
function isCodeAction(item: CodeAction | Command): item is CodeAction {
return "kind" in item || "edit" in item || "diagnostics" in item || "isPreferred" in item;
}
export function createCodeActionsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof CodeActionsParams, CodeActionsDetails> {
return {
name: "lsp_code_actions",
label: "LSP Code Actions",
description: "Get available code actions (quick fixes, refactorings, source actions) at a position or range. Returns actionable fixes the LSP server can suggest — auto-imports, remove unused, extract method, etc.",
promptSnippet: "Get available code actions (quick fixes, refactorings) at a file position via LSP. Use after lsp_diagnostics shows errors to find auto-fixes.",
parameters: CodeActionsParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0, preferredCount: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0, preferredCount: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0, preferredCount: 0 } };
}
// Check if server supports code actions
const caps = client.serverCapabilities;
if (caps && !caps.codeActionProvider) {
return {
content: [{ type: "text", text: "LSP server for this file does not support code actions." }],
details: { count: 0, preferredCount: 0 },
};
}
const uri = manager.getFileUri(filePath);
const startLine = line - 1;
const startChar = character - 1;
const endLine = (params.endLine ?? line) - 1;
const endChar = (params.endCharacter ?? character) - 1;
// Collect diagnostics that overlap with the requested range
const allDiags = client.getDiagnostics(uri) ?? [];
const rangeDiags = allDiags.filter((d: Diagnostic) =>
rangeContainsPosition(d.range, startLine, startChar) ||
rangeContainsPosition({ start: { line: startLine, character: startChar }, end: { line: endLine, character: endChar } }, d.range.start.line, d.range.start.character)
);
try {
const response = await client.sendRequest<CodeActionResponse>("textDocument/codeAction", {
textDocument: { uri },
range: {
start: { line: startLine, character: startChar },
end: { line: endLine, character: endChar },
},
context: {
diagnostics: rangeDiags,
only: params.kind ? [params.kind] : undefined,
},
});
if (!response || response.length === 0) {
const text = resolvedFrom
? `${resolvedFrom}\n\nNo code actions available at this position.`
: "No code actions available at this position.";
return { content: [{ type: "text", text }], details: { count: 0, preferredCount: 0 } };
}
// Separate CodeActions from Commands, sort: preferred first, then by kind
const actions: CodeAction[] = [];
const commands: Command[] = [];
for (const item of response) {
if (isCodeAction(item)) {
actions.push(item);
} else {
commands.push(item);
}
}
// Sort: preferred first, then quickfix > refactor > source > other
const kindOrder: Record<string, number> = { quickfix: 0, refactor: 1, source: 2 };
actions.sort((a, b) => {
if (a.isPreferred && !b.isPreferred) return -1;
if (!a.isPreferred && b.isPreferred) return 1;
const aKind = a.kind?.split(".")[0] ?? "zzz";
const bKind = b.kind?.split(".")[0] ?? "zzz";
return (kindOrder[aKind] ?? 3) - (kindOrder[bKind] ?? 3);
});
const rootDir = manager.resolvePath(".");
const outputLines: string[] = [];
let preferredCount = 0;
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const preferred = action.isPreferred ? "★ " : "";
if (action.isPreferred) preferredCount++;
const kindStr = action.kind ? ` [${action.kind}]` : "";
outputLines.push(` ${i + 1}. ${preferred}${action.title}${kindStr}`);
if (action.edit) {
const editLines = formatEditSummary(action.edit, rootDir);
outputLines.push(...editLines);
} else if (action.command && !action.edit) {
outputLines.push(` (command: ${action.command.title || action.command.command})`);
} else {
outputLines.push(" (resolve required)");
}
}
// Append plain commands at the end
for (const cmd of commands) {
outputLines.push(`${cmd.title} (command-only, requires IDE execution)`);
}
const totalCount = actions.length + commands.length;
const header = `${totalCount} code action(s) at ${filePath}:${line}:${character}`;
const preferredNote = preferredCount > 0 ? ` (${preferredCount} preferred)` : "";
let text = `${header}${preferredNote}\n\n${outputLines.join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return {
content: [{ type: "text", text }],
details: { count: totalCount, preferredCount },
};
} catch (err: any) {
return {
content: [{ type: "text", text: `LSP code action request failed: ${err.message}` }],
details: { count: 0, preferredCount: 0 },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_code_actions "));
if (args.query && !args.line) {
text += theme.fg("accent", args.path);
text += theme.fg("muted", ` query="${args.query}"`);
} else if (args.endLine) {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}-${args.endLine}:${args.endCharacter}`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
if (args.kind) {
text += theme.fg("dim", ` [${args.kind}]`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Loading..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) {
return new Text(theme.fg("dim", "No code actions available"), 0, 0);
}
const preferred = details.preferredCount > 0 ? ` (${details.preferredCount} preferred)` : "";
return new Text(theme.fg("success", `${details.count} action(s)${preferred}`), 0, 0);
},
};
}

View File

@@ -0,0 +1,233 @@
/**
* code_overview — Summarize project structure, key files, and symbols.
*
* Uses tree-sitter for symbol extraction. Shows:
* - Directory tree (respecting .gitignore, max depth ~3)
* - Top-level symbols per key file
* - Dependency manifests
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { resolve, relative } from "node:path";
import { readdir, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { extractSymbols } from "../tree-sitter/symbol-extractor.js";
import { SKIP_DIRS } from "../shared/constants.js";
// Shared constant imported from ../shared/constants.ts
/** Known dependency manifest files */
const MANIFESTS = [
"package.json", "Cargo.toml", "go.mod", "go.sum",
"pom.xml", "build.gradle", "build.gradle.kts",
"requirements.txt", "pyproject.toml", "setup.py", "setup.cfg",
"Gemfile", "Makefile", "CMakeLists.txt",
];
/** Known entry point patterns */
const ENTRY_PATTERNS = [
"index.ts", "index.js", "main.ts", "main.js", "app.ts", "app.js",
"main.py", "app.py", "__init__.py",
"main.rs", "lib.rs",
"main.go",
"Main.java", "Application.java",
];
const MAX_TREE_DEPTH = 3;
const MAX_TREE_ENTRIES = 200;
const OverviewParams = Type.Object({
path: Type.Optional(Type.String({ description: "Root directory to analyze (defaults to project root)" })),
depth: Type.Optional(Type.Number({ description: "Maximum directory depth (default: 3)" })),
});
interface OverviewDetails { files: number; symbols: number }
export function createCodeOverviewTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
workspaceIndex: WorkspaceIndex,
): ToolDefinition<typeof OverviewParams, OverviewDetails> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "code_overview",
label: "Code Overview",
description: "Summarize project structure: directory tree, top-level symbols per key file, dependency manifests. Uses tree-sitter for symbol extraction — no LSP required.",
promptSnippet: "Get a structural overview of the project (directories, key files, symbols)",
parameters: OverviewParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const targetDir = resolve(rootDir, params.path ?? ".");
const maxDepth = params.depth ?? MAX_TREE_DEPTH;
const sections: string[] = [];
let totalFiles = 0;
let totalSymbols = 0;
// 1. Directory tree
const treeLines: string[] = [];
await buildTree(targetDir, "", 0, maxDepth, treeLines);
totalFiles = treeLines.filter((l) => !l.endsWith("/")).length;
sections.push("## Directory Structure\n\n```\n" + treeLines.join("\n") + "\n```");
// 2. Dependency manifests
const manifests: string[] = [];
for (const m of MANIFESTS) {
const path = resolve(targetDir, m);
if (existsSync(path)) {
manifests.push(m);
}
}
if (manifests.length > 0) {
sections.push("## Dependency Manifests\n\n" + manifests.map((m) => `- ${m}`).join("\n"));
}
// 3. Key files with symbols
const keyFiles = await findKeyFiles(targetDir);
if (keyFiles.length > 0) {
const symbolSections: string[] = [];
for (const file of keyFiles.slice(0, 10)) {
try {
const content = await readFile(file, "utf-8");
const languageId = treeSitter.getLanguageId(file);
if (!languageId) continue;
const tree = await treeSitter.parse(file, content);
if (!tree) continue;
const symbols = extractSymbols(tree, languageId);
if (symbols.length === 0) continue;
const relPath = relative(targetDir, file);
const symbolLines = symbols.slice(0, 20).map((s) => {
const kindNames: Record<number, string> = {
5: "class", 6: "method", 10: "enum", 11: "interface",
12: "function", 13: "variable", 14: "constant", 22: "struct",
};
const kind = kindNames[s.kind] ?? "symbol";
return ` ${kind} ${s.name} (line ${s.line})`;
});
if (symbols.length > 20) {
symbolLines.push(` ... and ${symbols.length - 20} more`);
}
totalSymbols += symbols.length;
symbolSections.push(`### ${relPath}\n${symbolLines.join("\n")}`);
} catch { /* skip */ }
}
if (symbolSections.length > 0) {
sections.push("## Key Files\n\n" + symbolSections.join("\n\n"));
}
}
// 4. Workspace index stats
if (workspaceIndex.isBuilt) {
const stats = workspaceIndex.getStats();
sections.push(`## Index Stats\n\n- ${stats.files} indexed files\n- ${stats.symbols} symbols`);
}
const output = sections.join("\n\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = truncation.content;
if (truncation.truncated) {
text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text }],
details: { files: totalFiles, symbols: totalSymbols },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("code_overview "));
if (args.path) text += theme.fg("accent", args.path);
else text += theme.fg("dim", "(project root)");
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Analyzing..."), 0, 0);
const d = result.details;
if (!d) return new Text(theme.fg("dim", "No overview"), 0, 0);
return new Text(theme.fg("success", `${d.files} files, ${d.symbols} symbols`), 0, 0);
},
};
}
/** Build a directory tree representation */
async function buildTree(
dir: string,
prefix: string,
depth: number,
maxDepth: number,
lines: string[],
): Promise<void> {
if (depth > maxDepth || lines.length > MAX_TREE_ENTRIES) return;
try {
const entries = await readdir(dir, { withFileTypes: true });
// Sort: directories first, then files
const sorted = entries
.filter((e) => !e.name.startsWith(".") || e.name === ".github")
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (let i = 0; i < sorted.length; i++) {
if (lines.length > MAX_TREE_ENTRIES) {
lines.push(`${prefix}... (truncated)`);
return;
}
const entry = sorted[i];
const isLast = i === sorted.length - 1;
const connector = isLast ? "└── " : "├── ";
const childPrefix = isLast ? " " : "│ ";
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) {
lines.push(`${prefix}${connector}${entry.name}/ (skipped)`);
continue;
}
lines.push(`${prefix}${connector}${entry.name}/`);
await buildTree(
resolve(dir, entry.name),
prefix + childPrefix,
depth + 1,
maxDepth,
lines,
);
} else {
lines.push(`${prefix}${connector}${entry.name}`);
}
}
} catch { /* permission denied */ }
}
/** Find key/entry-point files in the project */
async function findKeyFiles(dir: string): Promise<string[]> {
const found: string[] = [];
// Check common entry point locations
const searchDirs = [dir, resolve(dir, "src"), resolve(dir, "lib"), resolve(dir, "app")];
for (const searchDir of searchDirs) {
for (const pattern of ENTRY_PATTERNS) {
const fullPath = resolve(searchDir, pattern);
if (existsSync(fullPath)) {
found.push(fullPath);
}
}
}
return [...new Set(found)]; // deduplicate
}

View File

@@ -0,0 +1,168 @@
/**
* code_rewrite — Transform code matching a structural pattern into a replacement.
*
* Matches code by AST structure (like ast_search), then applies a replacement
* template that can reference captured metavariables. Supports dry-run preview.
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { relative } from "node:path";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { compilePattern } from "../tree-sitter/pattern-compiler.js";
import { searchFiles } from "../tree-sitter/search-engine.js";
import { computeRewrites, applyRewrites } from "../tree-sitter/rewrite-engine.js";
/** Callback to notify when files are modified by rewrites */
export interface RewriteFileChangeCallback {
onFileModified(filePath: string): void;
}
const RewriteParams = Type.Object({
pattern: Type.String({ description: "Structural pattern to match (with $NAME / $$$NAME metavariables)" }),
replacement: Type.String({ description: "Replacement template using the same metavariables" }),
language: Type.String({ description: "Target language (typescript, python, rust, java, etc.)" }),
path: Type.Optional(Type.String({ description: "File or directory scope (default: workspace root)" })),
dry_run: Type.Optional(Type.Boolean({ description: "Preview changes without applying (default: true)" })),
});
interface RewriteDetails {
matchCount: number;
filesModified: number;
dryRun: boolean;
}
export function createCodeRewriteTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
fileChangeCallback?: RewriteFileChangeCallback,
): ToolDefinition<typeof RewriteParams> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "code_rewrite",
label: "Code Rewrite",
description:
"Transform code matching a structural pattern into a replacement. " +
"Use $NAME to capture and reuse single nodes, $$$NAME for sequences. " +
"Defaults to dry-run mode (preview only). Set dry_run=false to apply changes. " +
"For symbol renames, prefer lsp_rename instead (semantically correct).",
parameters: RewriteParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const { pattern: patternStr, replacement, language, path, dry_run } = params;
const isDryRun = dry_run !== false; // default true
// Compile the pattern
let compiled;
try {
compiled = await compilePattern(patternStr, language, treeSitter);
} catch (e: any) {
return {
content: [{ type: "text", text: `Error: ${e.message}` }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
// Validate that replacement references only known metavars
const knownVars = new Set(compiled.metavars);
const refRe = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
let refMatch;
while ((refMatch = refRe.exec(replacement)) !== null) {
const name = refMatch[1] ?? refMatch[2];
if (!knownVars.has(name)) {
return {
content: [{ type: "text", text: `Error: Replacement references $${name} but pattern doesn't capture it. Pattern captures: ${compiled.metavars.join(", ") || "(none)"}` }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
}
// Find matches
const matches = await searchFiles(compiled, rootDir, treeSitter, {
path,
maxResults: 500,
});
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No matches found. No changes to make." }],
details: { matchCount: 0, filesModified: 0, dryRun: isDryRun },
};
}
if (isDryRun) {
// Preview mode
const changes = computeRewrites(matches, replacement);
const lines: string[] = [];
lines.push(`Dry run: ${changes.length} change${changes.length !== 1 ? "s" : ""} would be made:\n`);
for (const c of changes) {
const relPath = relative(rootDir, c.file);
lines.push(`${relPath}:${c.line}:${c.column}`);
const beforeLines = c.before.split("\n");
const afterLines = c.after.split("\n");
for (const l of beforeLines) {
lines.push(` - ${l}`);
}
for (const l of afterLines) {
lines.push(` + ${l}`);
}
lines.push("");
}
lines.push("Run with dry_run=false to apply these changes.");
const text = lines.join("\n");
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
const uniqueFiles = new Set(changes.map((c) => c.file));
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesModified: uniqueFiles.size, dryRun: true },
};
}
// Apply mode
const result = await applyRewrites(matches, replacement);
// Notify FileSync about modified files so LSP servers get updated
if (fileChangeCallback && result.filesModified > 0) {
const modifiedFiles = new Set(result.changes.map((c) => c.file));
for (const file of modifiedFiles) {
fileChangeCallback.onFileModified(file);
}
}
const lines: string[] = [];
lines.push(`Applied ${result.changes.length} change${result.changes.length !== 1 ? "s" : ""} across ${result.filesModified} file${result.filesModified !== 1 ? "s" : ""}:\n`);
for (const c of result.changes) {
const relPath = relative(rootDir, c.file);
const beforeShort = c.before.length > 80 ? c.before.slice(0, 80) + "..." : c.before;
const afterShort = c.after.length > 80 ? c.after.slice(0, 80) + "..." : c.after;
lines.push(`${relPath}:${c.line}${beforeShort}${afterShort}`);
}
const text = lines.join("\n");
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesModified: result.filesModified, dryRun: false },
};
},
};
}

View File

@@ -0,0 +1,110 @@
/**
* ast_search — Find code matching a structural pattern with metavariables.
*
* Uses tree-sitter AST matching to find code by structure, not text.
* Supports `$NAME` for single-node wildcards and `$$$NAME` for variadic.
*/
import { Type } from "@sinclair/typebox";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { relative } from "node:path";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { compilePattern } from "../tree-sitter/pattern-compiler.js";
import { searchFiles } from "../tree-sitter/search-engine.js";
const SearchParams = Type.Object({
pattern: Type.String({ description: "Structural pattern with metavariables ($NAME for single node, $$$NAME for variadic)" }),
language: Type.String({ description: "Target language (typescript, python, rust, java, etc.)" }),
path: Type.Optional(Type.String({ description: "File or directory to search (default: workspace root)" })),
max_results: Type.Optional(Type.Number({ description: "Maximum results to return (default: 50)" })),
});
interface SearchDetails {
matchCount: number;
filesSearched: number;
}
export function createCodeSearchTool(
rootDirOrGetter: string | (() => string),
treeSitter: TreeSitterManager,
): ToolDefinition<typeof SearchParams> {
const getRootDir = typeof rootDirOrGetter === "function" ? rootDirOrGetter : () => rootDirOrGetter;
return {
name: "ast_search",
label: "Code Search",
description:
"Find code matching a structural pattern using AST matching. " +
"Use $NAME to match any single node, $$$NAME to match zero-or-more nodes. " +
"More precise than grep — matches code structure, not text.",
parameters: SearchParams,
async execute(_toolCallId, params) {
const rootDir = getRootDir();
const { pattern: patternStr, language, path, max_results } = params;
// Compile the pattern
let compiled;
try {
compiled = await compilePattern(patternStr, language, treeSitter);
} catch (e: any) {
return {
content: [{ type: "text", text: `Error: ${e.message}` }],
details: { matchCount: 0, filesSearched: 0 },
};
}
// Run the search
const matches = await searchFiles(compiled, rootDir, treeSitter, {
path,
maxResults: max_results ?? 50,
});
if (matches.length === 0) {
return {
content: [{ type: "text", text: "No matches found." }],
details: { matchCount: 0, filesSearched: 0 },
};
}
// Format results
const lines: string[] = [];
lines.push(`Found ${matches.length} match${matches.length !== 1 ? "es" : ""}:\n`);
for (const m of matches) {
const relPath = relative(rootDir, m.file);
const matchText = m.matchedText.length > 200
? m.matchedText.slice(0, 200) + "..."
: m.matchedText;
lines.push(`${relPath}:${m.line}:${m.column}`);
lines.push(` ${matchText.replace(/\n/g, "\n ")}`);
// Show captures
const captureEntries = Object.entries(m.captures);
if (captureEntries.length > 0) {
for (const [name, value] of captureEntries) {
const displayValue = value.length > 100 ? value.slice(0, 100) + "..." : value;
lines.push(` $${name} = ${displayValue}`);
}
}
lines.push("");
}
const text = lines.join("\n");
const uniqueFiles = new Set(matches.map((m) => m.file));
const truncation = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let output = truncation.content;
if (truncation.truncated) {
output += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
}
return {
content: [{ type: "text", text: output }],
details: { matchCount: matches.length, filesSearched: uniqueFiles.size },
};
},
};
}

View File

@@ -0,0 +1,421 @@
/**
* lsp_completions — Get completion suggestions at a position.
*
* Returns ranked completion items with type signatures and documentation,
* letting the LLM discover available methods, properties, and APIs.
*/
import { Type } from "@sinclair/typebox";
import type {
CompletionItem,
CompletionList,
CompletionItemKind,
MarkupContent,
} from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
import { SYNTHETIC_DOT_SETTLE_DELAY_MS } from "../shared/timing.js";
/** Map CompletionItemKind to human-readable labels */
const KIND_LABELS: Record<number, string> = {
1: "text",
2: "method",
3: "function",
4: "constructor",
5: "field",
6: "variable",
7: "class",
8: "interface",
9: "module",
10: "property",
11: "unit",
12: "value",
13: "enum",
14: "keyword",
15: "snippet",
16: "color",
17: "file",
18: "reference",
19: "folder",
20: "enum member",
21: "constant",
22: "struct",
23: "event",
24: "operator",
25: "type param",
};
function kindLabel(kind?: CompletionItemKind): string {
if (!kind) return "unknown";
return KIND_LABELS[kind] ?? "unknown";
}
/** Extract a short doc summary (first 1-2 lines) from documentation */
function docSummary(doc?: string | MarkupContent): string | undefined {
if (!doc) return undefined;
const text = typeof doc === "string" ? doc : doc.value;
if (!text) return undefined;
// Strip markdown code fences and take first 2 non-empty lines
const lines = text
.replace(/```[\s\S]*?```/g, "")
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const summary = lines.slice(0, 2).join(" ");
// Cap at 120 chars
return summary.length > 120 ? summary.slice(0, 117) + "..." : summary;
}
/** Format a single completion item as a compact line */
function formatItem(item: CompletionItem): string {
const kind = kindLabel(item.kind);
const label = item.label;
const detail = item.detail ? ` ${item.detail}` : "";
const labelDetail =
item.labelDetails?.detail ? item.labelDetails.detail : "";
const labelDesc =
item.labelDetails?.description ? `${item.labelDetails.description}` : "";
let line = `${kind.padEnd(12)} ${label}${labelDetail}${detail}${labelDesc}`;
const doc = docSummary(item.documentation);
if (doc) {
line += `\n${"".padEnd(13)}${doc}`;
}
return line;
}
type CompletionResponse = CompletionList | CompletionItem[] | null;
const CompletionParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
limit: Type.Optional(
Type.Number({ description: "Max results to return (default: 20)" })
),
trigger: Type.Optional(
Type.Union([Type.Literal("auto"), Type.Literal("none")], {
description:
'Synthetic trigger mode (default: "auto"). When "auto", if the position is at the end of an identifier (no trailing dot), a dot is temporarily inserted to trigger member completions. Use "none" to skip this.',
})
),
});
interface CompletionDetails {
count: number;
total: number;
}
/**
* Check if the position is at the end of an identifier (suitable for synthetic dot insertion).
* Returns the position after the identifier where the dot should be inserted, or null.
*/
function shouldSyntheticTrigger(
content: string,
line: number, // 0-indexed
character: number // 0-indexed
): { insertLine: number; insertChar: number } | null {
const lines = content.split("\n");
if (line < 0 || line >= lines.length) return null;
const lineText = lines[line];
if (character < 0 || character > lineText.length) return null;
// The character at `character` (0-indexed) should NOT be a dot already
if (lineText[character] === ".") return null;
// The character before `character` should be an identifier char (or closing paren/bracket)
const charBefore = character > 0 ? lineText[character - 1] : "";
if (!charBefore) return null;
// Accept identifier chars, closing parens/brackets (for chained calls like foo().bar)
if (/[\w\d_\)\]]/.test(charBefore)) {
return { insertLine: line, insertChar: character };
}
return null;
}
/**
* Insert a dot at the given position in the content string.
*/
function insertDot(content: string, line: number, character: number): string {
const lines = content.split("\n");
const lineText = lines[line];
lines[line] = lineText.slice(0, character) + "." + lineText.slice(character);
return lines.join("\n");
}
/** Interface for coordinating document versions with FileSync */
export interface VersionTracker {
getTrackedVersion(uri: string): number | null;
setTrackedVersion(uri: string, version: number): void;
/** Check if a synthetic dot operation is in progress for a URI */
isSyntheticDotActive(uri: string): boolean;
}
/** Simple per-URI lock to prevent concurrent synthetic dot + file write version conflicts */
export const syntheticDotLocks = new Set<string>();
export function createCompletionsTool(
manager: LspManager,
versionTracker?: VersionTracker,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof CompletionParams, CompletionDetails> {
return {
name: "lsp_completions",
label: "LSP Completions",
description:
'Get completion suggestions at a specific position in a file. Returns methods, properties, and other symbols available at that point. Useful for discovering APIs and verifying method names. Line and character are 1-indexed. When trigger is "auto" (default), a dot is temporarily inserted if the position is at the end of an identifier, enabling member completion without editing the file.',
promptSnippet:
'Get code completion suggestions at a file position via LSP. Use to discover available methods, properties, and APIs on objects. Supports automatic dot insertion for exploring members on identifiers.',
parameters: CompletionParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
const limit = params.limit ?? 20;
const trigger = params.trigger ?? "auto";
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line + 1;
character = resolved.character + 1;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}` }], details: { count: 0, total: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0, total: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return {
content: [
{ type: "text", text: manager.getUnavailableReason(filePath) },
],
details: { count: 0, total: 0 },
};
}
// Check if server supports completions
const caps = client.serverCapabilities;
if (caps && !caps.completionProvider) {
return {
content: [
{
type: "text",
text: `LSP server for this file does not support completions.`,
},
],
details: { count: 0, total: 0 },
};
}
const uri = manager.getFileUri(filePath);
const position = {
line: line - 1,
character: character - 1,
};
try {
// Synthetic trigger: if position is at end of identifier, temporarily insert "."
let syntheticDot = false;
let originalContent: string | null = null;
let revertVersion = 99991;
let completionPosition = position;
if (trigger === "auto") {
// Acquire per-URI lock to prevent concurrent version mutations
if (syntheticDotLocks.has(uri)) {
// Another synthetic dot operation is in progress — skip synthetic trigger
} else {
try {
syntheticDotLocks.add(uri);
const absPath = manager.resolvePath(filePath);
const fileContent = await readFile(absPath, "utf-8");
const triggerPos = shouldSyntheticTrigger(fileContent, position.line, position.character);
if (triggerPos) {
originalContent = fileContent;
const modifiedContent = insertDot(fileContent, triggerPos.insertLine, triggerPos.insertChar);
// Coordinate version with FileSync to avoid desync
const currentVersion = versionTracker?.getTrackedVersion(uri);
const insertVersion = currentVersion != null ? currentVersion + 1 : 99990;
revertVersion = currentVersion != null ? currentVersion + 2 : 99991;
// Send the modified content to the LSP server
client.didChange(uri, insertVersion, modifiedContent);
// Update FileSync so it knows about the version bump
if (versionTracker && currentVersion != null) {
versionTracker.setTrackedVersion(uri, insertVersion);
}
// The completion position is right after the inserted dot
completionPosition = {
line: triggerPos.insertLine,
character: triggerPos.insertChar + 1,
};
syntheticDot = true;
// Brief delay to let the LSP process the change
await new Promise((r) => setTimeout(r, SYNTHETIC_DOT_SETTLE_DELAY_MS));
}
} catch {
// If reading file or inserting dot fails, fall through to normal completion
} finally {
syntheticDotLocks.delete(uri);
}
}
}
let response: CompletionResponse;
try {
response = await client.sendRequest<CompletionResponse>(
"textDocument/completion",
{ textDocument: { uri }, position: completionPosition }
);
} finally {
// Always revert synthetic dot, even on error
if (syntheticDot && originalContent !== null) {
try {
client.didChange(uri, revertVersion, originalContent);
// Update FileSync with the final version after revert
if (versionTracker) {
versionTracker.setTrackedVersion(uri, revertVersion);
}
} catch {
// Best effort revert
}
}
}
if (!response) {
return {
content: [
{ type: "text", text: "No completions available at this position." },
],
details: { count: 0, total: 0 },
};
}
// Normalize to array
const allItems: CompletionItem[] = Array.isArray(response)
? response
: response.items;
if (allItems.length === 0) {
return {
content: [
{ type: "text", text: "No completions available at this position." },
],
details: { count: 0, total: 0 },
};
}
const total = allItems.length;
// Sort by sortText (LSP ranking), then take top N
const sorted = [...allItems].sort((a, b) => {
const sa = a.sortText ?? a.label;
const sb = b.sortText ?? b.label;
return sa.localeCompare(sb);
});
const topItems = sorted.slice(0, limit);
// Resolve items in parallel for full details (documentation, signatures)
const resolveSupported = caps?.completionProvider?.resolveProvider;
let resolvedItems: CompletionItem[];
if (resolveSupported) {
const resolveResults = await Promise.allSettled(
topItems.map((item) =>
Promise.race([
client.sendRequest<CompletionItem>(
"completionItem/resolve",
item
),
// Timeout per item: 2 seconds
new Promise<CompletionItem>((_, reject) =>
setTimeout(() => reject(new Error("resolve timeout")), 2000)
),
])
)
);
resolvedItems = resolveResults.map((result, i) =>
result.status === "fulfilled" ? result.value : topItems[i]
);
} else {
resolvedItems = topItems;
}
// Format output
const triggerNote = syntheticDot ? " (synthetic dot trigger)" : "";
let header = `${resolvedItems.length} of ${total} completions at ${filePath}:${line}:${character}${triggerNote}\n`;
if (resolvedFrom) header = `${resolvedFrom}\n\n${header}`;
const lines = resolvedItems.map(formatItem);
const text = header + lines.join("\n");
return {
content: [{ type: "text", text }],
details: { count: resolvedItems.length, total },
};
} catch (err: any) {
return {
content: [
{
type: "text",
text: `LSP completion request failed: ${err.message}`,
},
],
details: { count: 0, total: 0 },
};
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_completions "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
const extras: string[] = [];
if (args.limit) extras.push(`limit: ${args.limit}`);
if (args.trigger === "none") extras.push("trigger: none");
if (extras.length > 0) {
text += theme.fg("dim", ` (${extras.join(", ")})`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial)
return new Text(theme.fg("warning", "Loading completions..."), 0, 0);
if (!result.details || result.details.count === 0) {
const content = result.content[0];
if (content?.type === "text")
return new Text(theme.fg("dim", content.text), 0, 0);
return new Text(theme.fg("dim", "No completions"), 0, 0);
}
const { count, total } = result.details;
const summary = `${count} of ${total} completions`;
return new Text(theme.fg("dim", summary), 0, 0);
},
};
}

View File

@@ -0,0 +1,187 @@
/**
* lsp_definition — Go to the definition of a symbol.
*/
import { Type } from "@sinclair/typebox";
import type { Location, LocationLink } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { resolveProvider } from "../resolve-provider.js";
import { getNodeAtPosition, findDefinition } from "../tree-sitter/symbol-extractor.js";
import { formatLocation, formatLocationLink } from "../shared/format.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
import { relative } from "node:path";
type DefinitionResult = Location | Location[] | LocationLink[] | null;
const DefinitionParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
});
interface DefinitionDetails { count: number }
export function createDefinitionTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
workspaceIndex?: WorkspaceIndex | null,
): ToolDefinition<typeof DefinitionParams, DefinitionDetails> {
return {
name: "lsp_definition",
label: "LSP Definition",
description: "Go to the definition of a symbol at a specific position. Returns the file path and location of the definition. Line and character are 1-indexed.",
promptSnippet: "Jump to the definition of a symbol at a file position via LSP",
parameters: DefinitionParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const result = await client.sendRequest<DefinitionResult>("textDocument/definition", {
textDocument: { uri }, position,
});
if (!result) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo definition found.` : "No definition found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
let locations: string[];
if (Array.isArray(result)) {
if (result.length === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo definition found.` : "No definition found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
if ("targetUri" in result[0]) {
locations = (result as LocationLink[]).map((l) => formatLocationLink(l, rootDir));
} else {
locations = (result as Location[]).map((l) => formatLocation(l, rootDir));
}
} else {
locations = [formatLocation(result as Location, rootDir)];
}
let text = locations.length === 1
? `Definition: ${locations[0]}`
: `Definitions:\n${locations.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locations.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP definition request failed: ${err.message}` }], details: { count: 0 } };
}
}
// Tree-sitter fallback
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
// Get the symbol name at the cursor position
const node = getNodeAtPosition(tree, line - 1, character - 1);
if (node) {
const symbolName = node.text;
const rootDir = manager.resolvePath(".");
const relPath = relative(rootDir, absPath);
// Search current file first
const localDefs = findDefinition(tree, symbolName, provider.languageId);
if (localDefs.length > 0) {
const locs = localDefs.map((d) => `${relPath}:${d.line}:1`);
let text = locs.length === 1
? `Definition [tree-sitter]: ${locs[0]}`
: `Definitions [tree-sitter]:\n${locs.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locs.length } };
}
// Search workspace index
if (workspaceIndex) {
await workspaceIndex.build();
const entries = workspaceIndex.search(symbolName);
const exact = entries.filter((e) => e.name === symbolName);
if (exact.length > 0) {
const rootDir2 = manager.resolvePath(".");
const locs = exact.slice(0, 10).map((e) => {
const rel = relative(rootDir2, e.file);
return `${rel}:${e.line}:1`;
});
let text = locs.length === 1
? `Definition [tree-sitter]: ${locs[0]}`
: `Definitions [tree-sitter]:\n${locs.map((l) => ` ${l}`).join("\n")}`;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { count: locs.length } };
}
}
const msg = `No definition found for "${symbolName}" [tree-sitter]`;
const text = resolvedFrom ? `${resolvedFrom}\n\n${msg}` : msg;
return { content: [{ type: "text", text }], details: { count: 0 } };
}
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_definition "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Resolving..."), 0, 0);
const content = result.content[0];
if (content?.type === "text") return new Text(theme.fg("dim", content.text), 0, 0);
return new Text(theme.fg("dim", "No result"), 0, 0);
},
};
}

View File

@@ -0,0 +1,261 @@
/**
* lsp_diagnostics — Get compilation errors and warnings for a file or the whole workspace.
*
* When `path` is provided: returns diagnostics for that single file.
* When `path` is omitted: returns all cached diagnostics across all running LSP servers.
*/
import { Type } from "@sinclair/typebox";
import { DiagnosticSeverity, type Diagnostic } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveProvider } from "../resolve-provider.js";
import { getSyntaxErrors } from "../tree-sitter/symbol-extractor.js";
import { readFile } from "node:fs/promises";
import { relative } from "node:path";
import { fileURLToPath } from "node:url";
function severityToString(severity: number | undefined): string {
switch (severity) {
case DiagnosticSeverity.Error: return "error";
case DiagnosticSeverity.Warning: return "warning";
case DiagnosticSeverity.Information: return "info";
case DiagnosticSeverity.Hint: return "hint";
default: return "unknown";
}
}
function formatDiagnostic(diag: Diagnostic, filePath: string): string {
const line = diag.range.start.line + 1;
const col = diag.range.start.character + 1;
const sev = severityToString(diag.severity);
const source = diag.source ? ` [${diag.source}]` : "";
const code = diag.code !== undefined ? ` (${diag.code})` : "";
return `${filePath}:${line}:${col} ${sev}: ${diag.message}${code}${source}`;
}
const DiagnosticsParams = Type.Object({
path: Type.String({ description: "File path to get diagnostics for. Pass \"*\" to get all workspace diagnostics from all running LSP servers." }),
});
interface DiagnosticsDetails {
count: number;
errors?: number;
warnings?: number;
files?: number;
}
export function createDiagnosticsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof DiagnosticsParams, DiagnosticsDetails> {
return {
name: "lsp_diagnostics",
label: "LSP Diagnostics",
description: "Get compilation errors and warnings from the LSP server. Pass a file path to check a single file, or pass \"*\" to get all cached diagnostics across the workspace.",
promptSnippet: "Get compiler errors, warnings, and hints for a source file via LSP. Pass path=\"*\" to get all workspace diagnostics.",
promptGuidelines: [
"After making code changes with edit or write, use lsp_diagnostics to check for compilation errors before moving on.",
"To review all workspace diagnostics at once, call lsp_diagnostics with path=\"*\" — this returns all cached diagnostics from running LSP servers without needing to check files individually.",
],
parameters: DiagnosticsParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
// Workspace-wide mode
if (filePath === "*" || filePath === "") {
return executeWorkspaceDiagnostics(manager);
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const diagnostics = client.getDiagnostics(uri);
if (diagnostics.length === 0) {
return { content: [{ type: "text", text: "No diagnostics (clean)." }], details: { count: 0 } };
}
const sorted = [...diagnostics].sort((a, b) => (a.severity ?? 99) - (b.severity ?? 99));
const relPath = relative(manager.resolvePath("."), manager.resolvePath(filePath));
const lines = sorted.map((d) => formatDiagnostic(d, relPath));
const output = lines.join("\n");
const errors = sorted.filter((d) => d.severity === DiagnosticSeverity.Error).length;
const warnings = sorted.filter((d) => d.severity === DiagnosticSeverity.Warning).length;
const other = sorted.length - errors - warnings;
const summary = [
errors > 0 ? `${errors} error(s)` : null,
warnings > 0 ? `${warnings} warning(s)` : null,
other > 0 ? `${other} other` : null,
].filter(Boolean).join(", ");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${summary}\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text", text: resultText }],
details: { count: sorted.length, errors, warnings },
};
}
// Tree-sitter fallback: report syntax errors
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const syntaxErrors = getSyntaxErrors(tree);
if (syntaxErrors.length === 0) {
return { content: [{ type: "text", text: "No syntax errors detected. [tree-sitter — syntax only, no type checking]" }], details: { count: 0 } };
}
const relPath = relative(manager.resolvePath("."), absPath);
const lines = syntaxErrors.map((e) =>
`${relPath}:${e.line + 1}:${e.character + 1} error: ${e.message}`
);
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${syntaxErrors.length} syntax error(s) [tree-sitter — syntax only, no type checking]\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text", text: resultText }],
details: { count: syntaxErrors.length, errors: syntaxErrors.length },
};
}
} catch { /* fall through */ }
}
}
return {
content: [{ type: "text", text: manager.getUnavailableReason(filePath) }],
details: { count: 0 },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_diagnostics "));
if (args.path && args.path !== "*") {
text += theme.fg("accent", args.path);
} else {
text += theme.fg("dim", "(workspace)");
}
return new Text(text, 0, 0);
},
renderResult(result, { expanded, isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Checking..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) {
return new Text(theme.fg("success", "✓ No diagnostics"), 0, 0);
}
let text = "";
if (details.errors && details.errors > 0) text += theme.fg("error", `${details.errors} error(s)`);
if (details.warnings && details.warnings > 0) {
if (text) text += " ";
text += theme.fg("warning", `${details.warnings} warning(s)`);
}
if (details.files && details.files > 0) {
text += theme.fg("dim", ` in ${details.files} file(s)`);
}
if (expanded) {
const content = result.content[0];
if (content?.type === "text") {
const lines = content.text.split("\n").slice(0, 30);
for (const line of lines) text += `\n${theme.fg("dim", line)}`;
}
}
return new Text(text, 0, 0);
},
};
}
/** Collect all diagnostics from all running LSP servers */
function executeWorkspaceDiagnostics(manager: LspManager) {
const statuses = manager.getStatus();
const running = statuses.filter((s) => s.running);
if (running.length === 0) {
return {
content: [{ type: "text" as const, text: "No LSP servers are running. Use lsp_diagnostics with a file path to start a server and check that file." }],
details: { count: 0 },
};
}
const rootDir = manager.resolvePath(".");
const allDiagnostics: { relPath: string; diag: Diagnostic }[] = [];
for (const status of running) {
const client = manager.getRunningClient(status.languageId);
if (!client) continue;
const diagMap = client.getAllDiagnostics();
for (const [uri, diagnostics] of diagMap) {
if (diagnostics.length === 0) continue;
let absPath: string;
try {
absPath = fileURLToPath(uri);
} catch {
absPath = uri;
}
const relPath = relative(rootDir, absPath);
for (const diag of diagnostics) {
allDiagnostics.push({ relPath, diag });
}
}
}
if (allDiagnostics.length === 0) {
const langs = running.map((s) => s.languageId).join(", ");
return {
content: [{ type: "text" as const, text: `No diagnostics across ${running.length} running server(s) (${langs}).` }],
details: { count: 0 },
};
}
// Sort: errors first, then by file path
allDiagnostics.sort((a, b) => {
const sevDiff = (a.diag.severity ?? 99) - (b.diag.severity ?? 99);
if (sevDiff !== 0) return sevDiff;
return a.relPath.localeCompare(b.relPath);
});
const errors = allDiagnostics.filter((d) => d.diag.severity === DiagnosticSeverity.Error).length;
const warnings = allDiagnostics.filter((d) => d.diag.severity === DiagnosticSeverity.Warning).length;
const other = allDiagnostics.length - errors - warnings;
const fileCount = new Set(allDiagnostics.map((d) => d.relPath)).size;
const summary = [
`${allDiagnostics.length} diagnostic(s) in ${fileCount} file(s)`,
errors > 0 ? `${errors} error(s)` : null,
warnings > 0 ? `${warnings} warning(s)` : null,
other > 0 ? `${other} other` : null,
].filter(Boolean).join(", ");
const lines = allDiagnostics.map((d) => formatDiagnostic(d.diag, d.relPath));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${summary}\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} diagnostics]`;
}
return {
content: [{ type: "text" as const, text: resultText }],
details: { count: allDiagnostics.length, errors, warnings, files: fileCount },
};
}

View File

@@ -0,0 +1,153 @@
/**
* lsp_hover — Get type information and documentation at a position.
*/
import { Type } from "@sinclair/typebox";
import type { Hover, MarkupContent } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveProvider } from "../resolve-provider.js";
import { getEnclosingDeclaration, getSignatureText } from "../tree-sitter/symbol-extractor.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { readFile } from "node:fs/promises";
function formatHoverContent(hover: Hover): string {
const contents = hover.contents;
if (typeof contents === "string") return contents;
if ("kind" in contents && "value" in contents) return (contents as MarkupContent).value;
if ("language" in contents && "value" in contents) {
return `\`\`\`${(contents as any).language}\n${(contents as any).value}\n\`\`\``;
}
if (Array.isArray(contents)) {
return contents.map((c) => {
if (typeof c === "string") return c;
if ("language" in c && "value" in c) return `\`\`\`${c.language}\n${c.value}\n\`\`\``;
return String(c);
}).join("\n\n");
}
return String(contents);
}
const HoverParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
});
interface HoverDetails { hasResult: boolean }
export function createHoverTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof HoverParams, HoverDetails> {
return {
name: "lsp_hover",
label: "LSP Hover",
description: "Get type information and documentation for a symbol at a specific position in a file. Line and character are 1-indexed.",
promptSnippet: "Get type info and docs for a symbol at a file position via LSP",
parameters: HoverParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { hasResult: false } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { hasResult: false } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const hover = await client.sendRequest<Hover | null>("textDocument/hover", {
textDocument: { uri }, position,
});
if (!hover) {
const text = resolvedFrom
? `${resolvedFrom}\n\nNo hover information available at this position.`
: "No hover information available at this position.";
return { content: [{ type: "text", text }], details: { hasResult: false } };
}
const hoverText = formatHoverContent(hover);
const text = resolvedFrom ? `${resolvedFrom}\n\n${hoverText}` : hoverText;
return { content: [{ type: "text", text }], details: { hasResult: true } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP hover request failed: ${err.message}` }], details: { hasResult: false } };
}
}
// Tree-sitter fallback
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const decl = getEnclosingDeclaration(tree, line - 1, character - 1);
if (decl) {
const sig = getSignatureText(decl);
const kindLabel = decl.type.replace(/_/g, " ");
let text = `${kindLabel} [tree-sitter]\n\n\`\`\`\n${sig}\n\`\`\``;
if (resolvedFrom) text = `${resolvedFrom}\n\n${text}`;
return { content: [{ type: "text", text }], details: { hasResult: true } };
}
const text = resolvedFrom
? `${resolvedFrom}\n\nNo hover information available at this position. [tree-sitter]`
: "No hover information available at this position. [tree-sitter]";
return { content: [{ type: "text", text }], details: { hasResult: false } };
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { hasResult: false } };
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_hover "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Looking up..."), 0, 0);
if (!result.details?.hasResult) return new Text(theme.fg("dim", "No info"), 0, 0);
const content = result.content[0];
if (content?.type === "text") {
const lines = content.text.split("\n").slice(0, 5);
return new Text(lines.map((l) => theme.fg("dim", l)).join("\n"), 0, 0);
}
return new Text(theme.fg("dim", "No info"), 0, 0);
},
};
}

View File

@@ -0,0 +1,116 @@
/**
* lsp_references — Find all references to a symbol.
*/
import { Type } from "@sinclair/typebox";
import type { Location } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { formatLocation } from "../shared/format.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
const ReferencesParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
includeDeclaration: Type.Optional(
Type.Boolean({ description: "Include the declaration in results (default: true)" })
),
});
interface ReferencesDetails { count: number }
export function createReferencesTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof ReferencesParams, ReferencesDetails> {
return {
name: "lsp_references",
label: "LSP References",
description: "Find all references to a symbol at a specific position. Returns a list of file locations. Line and character are 1-indexed.",
promptSnippet: "Find all references to a symbol at a file position via LSP",
parameters: ReferencesParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { count: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { count: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
}
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const locations = await client.sendRequest<Location[] | null>("textDocument/references", {
textDocument: { uri }, position,
context: { includeDeclaration: params.includeDeclaration ?? true },
});
if (!locations || locations.length === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo references found.` : "No references found.";
return { content: [{ type: "text", text }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const formatted = locations.map((l) => formatLocation(l, rootDir));
const output = formatted.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let resultText = `${locations.length} reference(s) found:\n\n${truncation.content}`;
if (truncation.truncated) {
resultText += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} references]`;
}
if (resolvedFrom) resultText = `${resolvedFrom}\n\n${resultText}`;
return { content: [{ type: "text", text: resultText }], details: { count: locations.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP references request failed: ${err.message}` }], details: { count: 0 } };
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_references "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}"`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Searching..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) return new Text(theme.fg("dim", "No references found"), 0, 0);
return new Text(theme.fg("success", `${details.count} reference(s)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,160 @@
/**
* lsp_rename — Preview a rename refactoring across the workspace.
* Returns planned edits but does NOT apply them.
*/
import { Type } from "@sinclair/typebox";
import type { WorkspaceEdit, TextEdit } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import { resolveSymbolPosition, getSymbolNames } from "../shared/resolve-position.js";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
function formatWorkspaceEdit(edit: WorkspaceEdit, rootDir: string): { summary: string; fileCount: number; editCount: number } {
const lines: string[] = [];
let totalEdits = 0;
let fileCount = 0;
if (edit.documentChanges) {
for (const change of edit.documentChanges) {
if ("textDocument" in change && "edits" in change) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(change.textDocument.uri)); } catch { relPath = change.textDocument.uri; }
lines.push(`${relPath}:`);
for (const textEdit of change.edits as TextEdit[]) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
}
}
const changes = edit.changes ?? {};
for (const [uri, edits] of Object.entries(changes)) {
fileCount++;
let relPath: string;
try { relPath = relative(rootDir, fileURLToPath(uri)); } catch { relPath = uri; }
lines.push(`${relPath}:`);
for (const textEdit of edits) {
const line = textEdit.range.start.line + 1;
const col = textEdit.range.start.character + 1;
lines.push(` ${line}:${col} → "${textEdit.newText}"`);
totalEdits++;
}
}
return { summary: lines.join("\n"), fileCount, editCount: totalEdits };
}
const RenameParams = Type.Object({
path: Type.String({ description: "File path" }),
line: Type.Optional(Type.Number({ description: "Line number (1-indexed). Required unless query is provided." })),
character: Type.Optional(Type.Number({ description: "Column number (1-indexed). Required unless query is provided." })),
query: Type.Optional(Type.String({ description: "Symbol name to find in the file. Alternative to line/character — resolves the symbol's position automatically." })),
newName: Type.String({ description: "New name for the symbol" }),
});
interface RenameDetails { fileCount: number; editCount: number }
export function createRenameTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
): ToolDefinition<typeof RenameParams, RenameDetails> {
return {
name: "lsp_rename",
label: "LSP Rename",
description: "Preview a rename refactoring for a symbol at a position. Returns the list of changes that would be made across all files. Does NOT apply the changes — use edit/write tools to apply them. Line and character are 1-indexed.",
promptSnippet: "Preview rename refactoring for a symbol (returns planned edits, does not apply them)",
parameters: RenameParams,
async execute(_toolCallId, params) {
const filePath = params.path.replace(/^@/, "");
let line = params.line;
let character = params.character;
let resolvedFrom: string | undefined;
// Resolve position from query if line/character not provided
if ((line === undefined || character === undefined) && params.query) {
const resolved = await resolveSymbolPosition(filePath, params.query, manager, treeSitter);
if (resolved) {
line = resolved.line;
character = resolved.character;
resolvedFrom = `Resolved "${params.query}" → ${resolved.symbolName} at ${line}:${character} [${resolved.source}]`;
} else {
const names = await getSymbolNames(filePath, manager, treeSitter);
const hint = names.length > 0 ? `\nAvailable symbols: ${names.slice(0, 20).join(", ")}` : "";
return { content: [{ type: "text", text: `Could not find symbol "${params.query}" in ${filePath}${hint}` }], details: { fileCount: 0, editCount: 0 } };
}
}
if (line === undefined || character === undefined) {
return { content: [{ type: "text", text: "Either line/character or query is required." }], details: { fileCount: 0, editCount: 0 } };
}
const client = await manager.getClientForFile(filePath).catch(() => null);
if (!client) {
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { fileCount: 0, editCount: 0 } };
}
const uri = manager.getFileUri(filePath);
const position = { line: line - 1, character: character - 1 };
try {
const result = await client.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
textDocument: { uri }, position, newName: params.newName,
});
if (!result) {
const text = resolvedFrom ? `${resolvedFrom}\n\nRename not possible at this position.` : "Rename not possible at this position.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const rootDir = manager.resolvePath(".");
const { summary, fileCount, editCount } = formatWorkspaceEdit(result, rootDir);
if (editCount === 0) {
const text = resolvedFrom ? `${resolvedFrom}\n\nNo edits needed for this rename.` : "No edits needed for this rename.";
return { content: [{ type: "text", text }], details: { fileCount: 0, editCount: 0 } };
}
const truncation = truncateHead(summary, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = "";
if (resolvedFrom) text += `${resolvedFrom}\n\n`;
text += `Rename "${params.newName}": ${editCount} edit(s) across ${fileCount} file(s)\n\n`;
text += "NOTE: These changes are NOT applied. Use edit/write tools to make the changes.\n\n";
text += truncation.content;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { fileCount, editCount } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP rename request failed: ${err.message}` }], details: { fileCount: 0, editCount: 0 } };
}
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_rename "));
if (args.query && !args.line) {
text += theme.fg("accent", `${args.path}`);
text += theme.fg("muted", ` query="${args.query}" → ${args.newName}`);
} else {
text += theme.fg("accent", `${args.path}:${args.line}:${args.character}`);
text += theme.fg("muted", `${args.newName}`);
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Computing..."), 0, 0);
const details = result.details;
if (!details || details.editCount === 0) return new Text(theme.fg("dim", "No edits"), 0, 0);
return new Text(theme.fg("success", `${details.editCount} edit(s) in ${details.fileCount} file(s) (preview only)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,234 @@
/**
* lsp_symbols — List symbols in a file or search workspace symbols.
*/
import { Type } from "@sinclair/typebox";
import { SymbolKind, type DocumentSymbol, type SymbolInformation, type WorkspaceSymbol } from "vscode-languageserver-protocol";
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
import { truncateHead, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import type { LspManager } from "../lsp-manager.js";
import type { TreeSitterManager } from "../tree-sitter/parser-manager.js";
import type { WorkspaceIndex } from "../tree-sitter/workspace-index.js";
import { resolveProvider } from "../resolve-provider.js";
import { extractSymbols, type SymbolInfo } from "../tree-sitter/symbol-extractor.js";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { relative } from "node:path";
const SYMBOL_KIND_NAMES: Record<number, string> = {
[SymbolKind.File]: "file", [SymbolKind.Module]: "module", [SymbolKind.Namespace]: "namespace",
[SymbolKind.Package]: "package", [SymbolKind.Class]: "class", [SymbolKind.Method]: "method",
[SymbolKind.Property]: "property", [SymbolKind.Field]: "field", [SymbolKind.Constructor]: "constructor",
[SymbolKind.Enum]: "enum", [SymbolKind.Interface]: "interface", [SymbolKind.Function]: "function",
[SymbolKind.Variable]: "variable", [SymbolKind.Constant]: "constant", [SymbolKind.String]: "string",
[SymbolKind.Number]: "number", [SymbolKind.Boolean]: "boolean", [SymbolKind.Array]: "array",
[SymbolKind.Object]: "object", [SymbolKind.Key]: "key", [SymbolKind.Null]: "null",
[SymbolKind.EnumMember]: "enum-member", [SymbolKind.Struct]: "struct", [SymbolKind.Event]: "event",
[SymbolKind.Operator]: "operator", [SymbolKind.TypeParameter]: "type-param",
};
function kindName(kind: SymbolKind): string {
return SYMBOL_KIND_NAMES[kind] ?? `kind(${kind})`;
}
function formatDocumentSymbol(sym: DocumentSymbol, indent: number = 0): string[] {
const prefix = " ".repeat(indent);
const line = sym.range.start.line + 1;
const result = [`${prefix}${kindName(sym.kind)} ${sym.name} (line ${line})`];
if (sym.children) {
for (const child of sym.children) result.push(...formatDocumentSymbol(child, indent + 1));
}
return result;
}
function formatSymbolInfo(sym: SymbolInformation | WorkspaceSymbol, rootDir: string): string {
let location = "";
if ("location" in sym && sym.location) {
try {
const absPath = fileURLToPath(sym.location.uri);
const relPath = relative(rootDir, absPath);
const loc = sym.location as any;
const line = loc.range ? loc.range.start.line + 1 : "?";
location = ` ${relPath}:${line}`;
} catch {
location = ` ${sym.location.uri}`;
}
}
return `${kindName(sym.kind)} ${sym.name}${location}`;
}
const SymbolsParams = Type.Object({
path: Type.Optional(Type.String({ description: "File path for document symbols" })),
query: Type.Optional(Type.String({ description: "Search query for workspace symbols (searches across all files)" })),
});
interface SymbolsDetails { count: number }
function formatTreeSitterSymbol(sym: SymbolInfo, indent: number = 0): string[] {
const prefix = " ".repeat(indent);
const kindStr = SYMBOL_KIND_NAMES[sym.kind] ?? `kind(${sym.kind})`;
const result = [`${prefix}${kindStr} ${sym.name} (line ${sym.line})`];
if (sym.children) {
for (const child of sym.children) result.push(...formatTreeSitterSymbol(child, indent + 1));
}
return result;
}
export function createSymbolsTool(
manager: LspManager,
treeSitter?: TreeSitterManager | null,
workspaceIndex?: WorkspaceIndex | null,
): ToolDefinition<typeof SymbolsParams, SymbolsDetails> {
return {
name: "lsp_symbols",
label: "LSP Symbols",
description: "List symbols in a file (document symbols) or search for symbols across the workspace. Provide 'path' for file symbols or 'query' for workspace search.",
promptSnippet: "List symbols in a file or search workspace symbols via LSP",
parameters: SymbolsParams,
async execute(_toolCallId, params) {
const filePath = params.path?.replace(/^@/, "");
const query = params.query;
if (!filePath && query === undefined) {
return {
content: [{ type: "text", text: "Please provide either 'path' for file symbols or 'query' for workspace symbol search." }],
details: { count: 0 },
};
}
// Document symbols
if (filePath) {
const client = await manager.getClientForFile(filePath).catch(() => null);
if (client) {
// LSP path
const uri = manager.getFileUri(filePath);
try {
const result = await client.sendRequest<DocumentSymbol[] | SymbolInformation[] | null>(
"textDocument/documentSymbol", { textDocument: { uri } }
);
if (!result || result.length === 0) {
return { content: [{ type: "text", text: "No symbols found in this file." }], details: { count: 0 } };
}
let lines: string[];
if ("range" in result[0]) {
lines = (result as DocumentSymbol[]).flatMap((s) => formatDocumentSymbol(s));
} else {
const rootDir = manager.resolvePath(".");
lines = (result as SymbolInformation[]).map((s) => formatSymbolInfo(s, rootDir));
}
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${lines.length} symbol(s):\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: lines.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP document symbols request failed: ${err.message}` }], details: { count: 0 } };
}
}
// Tree-sitter fallback for document symbols
if (treeSitter) {
const provider = resolveProvider(filePath, manager, treeSitter);
if (provider.type === "tree-sitter") {
try {
const absPath = manager.resolvePath(filePath);
const content = await readFile(absPath, "utf-8");
const tree = await treeSitter.parse(absPath, content);
if (tree) {
const symbols = extractSymbols(tree, provider.languageId);
if (symbols.length === 0) {
return { content: [{ type: "text", text: "No symbols found in this file." }], details: { count: 0 } };
}
const lines = symbols.flatMap((s) => formatTreeSitterSymbol(s));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${lines.length} symbol(s) [tree-sitter]:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: lines.length } };
}
} catch { /* fall through */ }
}
}
return { content: [{ type: "text", text: manager.getUnavailableReason(filePath) }], details: { count: 0 } };
}
// Workspace symbol search
const statuses = manager.getStatus();
const runningLang = statuses.find((s) => s.running)?.languageId;
if (runningLang) {
const client = await manager.getClientForLanguage(runningLang).catch(() => null);
if (client) {
try {
const result = await client.sendRequest<(SymbolInformation | WorkspaceSymbol)[] | null>(
"workspace/symbol", { query: query ?? "" }
);
if (!result || result.length === 0) {
return { content: [{ type: "text", text: `No workspace symbols found for query: "${query}"` }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const lines = result.map((s) => formatSymbolInfo(s, rootDir));
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${result.length} symbol(s) found:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: result.length } };
} catch (err: any) {
return { content: [{ type: "text", text: `LSP workspace symbols request failed: ${err.message}` }], details: { count: 0 } };
}
}
}
// Tree-sitter fallback for workspace symbol search
if (workspaceIndex && query) {
try {
await workspaceIndex.build();
const results = workspaceIndex.search(query);
if (results.length === 0) {
return { content: [{ type: "text", text: `No workspace symbols found for query: "${query}" [tree-sitter]` }], details: { count: 0 } };
}
const rootDir = manager.resolvePath(".");
const lines = results.map((e) => {
const relPath = relative(rootDir, e.file);
return `${kindName(e.kind)} ${e.name} ${relPath}:${e.line}`;
});
const output = lines.join("\n");
const truncation = truncateHead(output, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES });
let text = `${results.length} symbol(s) found [tree-sitter]:\n\n${truncation.content}`;
if (truncation.truncated) text += `\n\n[Truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines]`;
return { content: [{ type: "text", text }], details: { count: results.length } };
} catch { /* fall through */ }
}
return {
content: [{ type: "text", text: "No LSP servers are currently running and no workspace index is available. Use lsp_diagnostics or lsp_hover on a file first to start a server." }],
details: { count: 0 },
};
},
renderCall(args, theme) {
let text = theme.fg("toolTitle", theme.bold("lsp_symbols "));
if (args.path) text += theme.fg("accent", args.path);
if (args.query) text += theme.fg("accent", `query="${args.query}"`);
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme) {
if (isPartial) return new Text(theme.fg("warning", "Loading symbols..."), 0, 0);
const details = result.details;
if (!details || details.count === 0) return new Text(theme.fg("dim", "No symbols"), 0, 0);
return new Text(theme.fg("success", `${details.count} symbol(s)`), 0, 0);
},
};
}

View File

@@ -0,0 +1,212 @@
/**
* Tree-sitter Parser Manager — loads and caches WASM parsers per language.
*
* Uses web-tree-sitter (WASM) so no native compilation is needed.
* Grammar .wasm files come from the tree-sitter-wasms npm package.
*/
import { createRequire } from "node:module";
import { dirname, resolve } from "node:path";
import Parser from "web-tree-sitter";
const require = createRequire(import.meta.url);
import { getLanguageIdFromPath } from "../shared/language-map.js";
type Language = Parser.Language;
type Tree = Parser.Tree;
/** Map language IDs to tree-sitter-wasms grammar file names */
const LANGUAGE_TO_GRAMMAR: Record<string, string> = {
typescript: "tree-sitter-typescript.wasm",
typescriptreact: "tree-sitter-tsx.wasm",
javascript: "tree-sitter-javascript.wasm",
javascriptreact: "tree-sitter-javascript.wasm",
python: "tree-sitter-python.wasm",
rust: "tree-sitter-rust.wasm",
go: "tree-sitter-go.wasm",
java: "tree-sitter-java.wasm",
c: "tree-sitter-c.wasm",
cpp: "tree-sitter-cpp.wasm",
ruby: "tree-sitter-ruby.wasm",
kotlin: "tree-sitter-kotlin.wasm",
scala: "tree-sitter-scala.wasm",
swift: "tree-sitter-swift.wasm",
lua: "tree-sitter-lua.wasm",
bash: "tree-sitter-bash.wasm",
json: "tree-sitter-json.wasm",
html: "tree-sitter-html.wasm",
css: "tree-sitter-css.wasm",
};
// File extension → language ID mapping is in shared/language-map.ts
interface CachedTree {
tree: Tree;
contentLength: number;
hashHead: number;
hashTail: number;
}
export class TreeSitterManager {
private initialized = false;
private initializing: Promise<void> | null = null;
private languages: Map<string, Language> = new Map();
private loadingLanguages: Map<string, Promise<Language | null>> = new Map();
private parsers: Map<string, Parser> = new Map();
private cachedTrees: Map<string, CachedTree> = new Map();
private grammarsDir: string;
constructor() {
// Use Node's module resolution so this works for local, global, and hoisted installs
const wasmsPkgJson = require.resolve("tree-sitter-wasms/package.json");
this.grammarsDir = resolve(dirname(wasmsPkgJson), "out");
}
/** Initialize web-tree-sitter WASM runtime. Must be called before any parsing. */
async init(): Promise<void> {
if (this.initialized) return;
if (this.initializing) {
await this.initializing;
return;
}
this.initializing = Parser.init({
locateFile: (scriptName: string) => {
// Use module resolution to find web-tree-sitter regardless of install layout
const webTsPkgJson = require.resolve("web-tree-sitter/package.json");
return resolve(dirname(webTsPkgJson), scriptName);
},
});
await this.initializing;
this.initialized = true;
}
/** Get the language ID for a file path based on extension */
getLanguageId(filePath: string): string | undefined {
return getLanguageIdFromPath(filePath);
}
/** Check if we have a grammar available for a language */
hasGrammar(languageId: string): boolean {
return languageId in LANGUAGE_TO_GRAMMAR;
}
/** Get all supported language IDs */
getSupportedLanguages(): string[] {
return Object.keys(LANGUAGE_TO_GRAMMAR);
}
/** Load a language grammar, caching the result */
async getLanguage(languageId: string): Promise<Language | null> {
const cached = this.languages.get(languageId);
if (cached) return cached;
// Deduplicate concurrent loads
const loading = this.loadingLanguages.get(languageId);
if (loading) return loading;
const grammarFile = LANGUAGE_TO_GRAMMAR[languageId];
if (!grammarFile) return null;
const loadPromise = (async (): Promise<Language | null> => {
await this.init();
try {
const grammarPath = resolve(this.grammarsDir, grammarFile);
const language = await Parser.Language.load(grammarPath);
this.languages.set(languageId, language);
return language;
} catch {
return null;
} finally {
this.loadingLanguages.delete(languageId);
}
})();
this.loadingLanguages.set(languageId, loadPromise);
return loadPromise;
}
/** Get or create a parser for a language */
private async getParser(languageId: string): Promise<Parser | null> {
const existing = this.parsers.get(languageId);
if (existing) return existing;
const language = await this.getLanguage(languageId);
if (!language) return null;
const parser = new Parser();
parser.setLanguage(language);
this.parsers.set(languageId, parser);
return parser;
}
/** Parse a file's content and return the tree. Caches by file path + content hash. */
async parse(filePath: string, content: string): Promise<Tree | null> {
const languageId = this.getLanguageId(filePath);
if (!languageId) return null;
return this.parseWithLanguage(filePath, content, languageId);
}
/** Parse content with an explicit language ID */
async parseWithLanguage(filePath: string, content: string, languageId: string): Promise<Tree | null> {
const length = content.length;
const head = djb2Hash(content, 0, Math.min(length, 4096));
const tail = length > 4096 ? djb2Hash(content, Math.max(0, length - 4096), length) : head;
const cached = this.cachedTrees.get(filePath);
if (cached && cached.contentLength === length && cached.hashHead === head && cached.hashTail === tail) {
return cached.tree;
}
const parser = await this.getParser(languageId);
if (!parser) return null;
const tree = parser.parse(content);
if (!tree) return null;
// Evict old tree
if (cached) cached.tree.delete();
this.cachedTrees.set(filePath, { tree, contentLength: length, hashHead: head, hashTail: tail });
return tree;
}
/** Invalidate the cached tree for a file */
invalidate(filePath: string): void {
const cached = this.cachedTrees.get(filePath);
if (cached) {
cached.tree.delete();
this.cachedTrees.delete(filePath);
}
}
/** Get a cached tree without re-parsing */
getCachedTree(filePath: string): Tree | null {
return this.cachedTrees.get(filePath)?.tree ?? null;
}
/** Shut down — free all resources */
shutdown(): void {
for (const [, cached] of this.cachedTrees) cached.tree.delete();
this.cachedTrees.clear();
for (const [, parser] of this.parsers) parser.delete();
this.parsers.clear();
this.languages.clear();
}
/** Alias for shutdown() — conventional dispose pattern for extension lifecycle */
dispose(): void {
this.shutdown();
}
}
/**
* Fast non-cryptographic hash (djb2) over a range of a string.
* Hashing head + tail separately gives collision resistance close to
* a full-content hash without iterating every character of large files.
*/
function djb2Hash(str: string, start: number, end: number): number {
let hash = 5381;
for (let i = start; i < end; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return hash;
}

View File

@@ -0,0 +1,353 @@
/**
* Pattern Compiler — parse metavariable patterns into matchable pattern trees.
*
* User-facing patterns use `$NAME` for single-node wildcards and `$$$NAME` for
* variadic (zero-or-more) wildcards. The pattern string is parsed as code using
* tree-sitter, then the AST is walked to build a PatternNode tree where
* metavariable identifiers are replaced with wildcard matchers.
*/
import type Parser from "web-tree-sitter";
import type { TreeSitterManager } from "./parser-manager.js";
type SyntaxNode = Parser.SyntaxNode;
// ── Pattern node types ──────────────────────────────────────────────────────
export type PatternNode =
| LiteralPatternNode
| MetavarPatternNode
| VariadicPatternNode;
/** Matches a concrete AST node — type must match, and either text (leaf) or children (branch) */
export interface LiteralPatternNode {
kind: "literal";
/** tree-sitter node type to match (e.g., "identifier", "call_expression") */
nodeType: string;
/** For leaf nodes: exact text that must match */
text?: string;
/** For branch nodes: child patterns to match (named children only) */
children: PatternNode[];
/** Field name this node occupies in its parent (e.g., "function", "arguments") */
fieldName?: string;
}
/** Matches any single AST node and captures its text */
export interface MetavarPatternNode {
kind: "metavar";
/** Capture name (e.g., "ARG" from "$ARG") */
name: string;
/** Field name this node occupies in its parent */
fieldName?: string;
}
/** Matches zero or more consecutive sibling nodes */
export interface VariadicPatternNode {
kind: "variadic";
/** Capture name (e.g., "PARAMS" from "$$$PARAMS", or "" for anonymous "$$$") */
name: string;
/** Field name context */
fieldName?: string;
}
export interface CompiledPattern {
/** The root pattern node (unwrapped from program/expression_statement wrappers) */
root: PatternNode;
/** All metavariable names found in the pattern */
metavars: string[];
/** The language this pattern was compiled for */
languageId: string;
}
// ── Metavariable detection ──────────────────────────────────────────────────
const VARIADIC_RE = /^\$\$\$([A-Z_][A-Z0-9_]*)?$/;
const METAVAR_RE = /^\$([A-Z_][A-Z0-9_]*)$/;
function isVariadic(text: string): string | null {
const m = VARIADIC_RE.exec(text);
return m ? (m[1] ?? "") : null;
}
function isMetavar(text: string): string | null {
const m = METAVAR_RE.exec(text);
return m ? m[1] : null;
}
// ── Metavar placeholder encoding ────────────────────────────────────────────
const META_PREFIX = "__META_";
const VMETA_PREFIX = "__VMETA_";
const META_SUFFIX = "__";
/**
* Replace `$$$NAME` and `$NAME` with placeholder identifiers valid in any language.
* Returns the preprocessed source and a map from placeholder to {kind, name}.
*/
function preprocessMetavars(source: string): {
preprocessed: string;
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>;
} {
const placeholders = new Map<string, { kind: "metavar" | "variadic"; name: string }>();
// Replace variadic first (longer prefix) to avoid $$ matching $
let preprocessed = source.replace(/\$\$\$([A-Z_][A-Z0-9_]*)?/g, (_match, name) => {
const n = name ?? "";
const placeholder = `${VMETA_PREFIX}${n || "ANON"}${META_SUFFIX}`;
placeholders.set(placeholder, { kind: "variadic", name: n });
return placeholder;
});
preprocessed = preprocessed.replace(/\$([A-Z_][A-Z0-9_]*)/g, (_match, name) => {
const placeholder = `${META_PREFIX}${name}${META_SUFFIX}`;
placeholders.set(placeholder, { kind: "metavar", name });
return placeholder;
});
return { preprocessed, placeholders };
}
/** Check if a text is a metavar placeholder */
function decodePlaceholder(
text: string,
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>,
): { kind: "metavar" | "variadic"; name: string } | null {
return placeholders.get(text) ?? null;
}
// ── Compile ─────────────────────────────────────────────────────────────────
/**
* Compile a user-facing pattern string into a matchable PatternNode tree.
*
* Metavariables ($NAME, $$$NAME) are replaced with placeholder identifiers
* before parsing, so patterns work in any language regardless of whether `$`
* is a valid identifier character.
*
* Tries parsing the pattern as-is first, then with wrapper contexts if it
* contains syntax errors (e.g., an expression fragment that isn't a valid program).
*/
export async function compilePattern(
source: string,
languageId: string,
treeSitter: TreeSitterManager,
): Promise<CompiledPattern> {
await treeSitter.init();
const { preprocessed, placeholders } = preprocessMetavars(source);
// Language-specific wrappers for fragments that aren't full programs
const wrappers = getWrappersForLanguage(languageId);
for (const { wrap, unwrap } of wrappers) {
const wrapped = wrap(preprocessed);
const tree = await treeSitter.parseWithLanguage(
`__pattern__${Date.now()}`,
wrapped,
languageId,
);
if (!tree) continue;
const root = tree.rootNode;
if (hasErrors(root)) continue;
const unwrapped = unwrap(root);
if (!unwrapped) continue;
const metavars: string[] = [];
const patternNode = buildPatternNode(unwrapped, metavars, undefined, placeholders);
return { root: patternNode, metavars: [...new Set(metavars)], languageId };
}
throw new Error(
`Failed to parse pattern as ${languageId}. The pattern may have syntax errors ` +
`or may not be a valid code fragment in this language.`,
);
}
/** Get wrapper strategies appropriate for a language */
function getWrappersForLanguage(languageId: string): Array<{
wrap: (s: string) => string;
unwrap: (root: SyntaxNode) => SyntaxNode | null;
}> {
const common = [
{ wrap: (s: string) => s, unwrap: unwrapProgram },
{ wrap: (s: string) => `${s};`, unwrap: unwrapProgram },
{ wrap: (s: string) => `(${s})`, unwrap: unwrapExpression },
];
switch (languageId) {
case "python":
return [
{ wrap: (s: string) => s, unwrap: unwrapModule },
{ wrap: (s: string) => `(${s})`, unwrap: unwrapExpression },
{ wrap: (s: string) => `def _():\n ${s}`, unwrap: unwrapPythonFunctionBody },
];
default:
return [
...common,
{ wrap: (s: string) => `function _() { ${s} }`, unwrap: unwrapFunctionBody },
];
}
}
// ── AST → PatternNode conversion ────────────────────────────────────────────
function buildPatternNode(
node: SyntaxNode,
metavars: string[],
fieldName: string | undefined,
placeholders: Map<string, { kind: "metavar" | "variadic"; name: string }>,
): PatternNode {
const text = node.text;
// Check for placeholder-based metavar/variadic (new: language-agnostic)
if (node.namedChildCount === 0) {
const decoded = decodePlaceholder(text, placeholders);
if (decoded) {
if (decoded.name) metavars.push(decoded.name);
if (decoded.kind === "variadic") {
return { kind: "variadic", name: decoded.name, fieldName };
}
return { kind: "metavar", name: decoded.name, fieldName };
}
}
// Also check legacy $-prefixed metavars (for languages where $ is valid)
if (node.namedChildCount === 0) {
const variadicName = isVariadic(text);
if (variadicName !== null) {
if (variadicName) metavars.push(variadicName);
return { kind: "variadic", name: variadicName, fieldName };
}
const metavarName = isMetavar(text);
if (metavarName !== null) {
metavars.push(metavarName);
return { kind: "metavar", name: metavarName, fieldName };
}
}
// Branch node with a single child that is a placeholder → unwrap
if (node.namedChildCount === 1) {
const onlyChild = node.namedChildren[0];
if (onlyChild.namedChildCount === 0) {
const decoded = decodePlaceholder(onlyChild.text, placeholders);
if (decoded) {
if (decoded.name) metavars.push(decoded.name);
if (decoded.kind === "variadic") {
return { kind: "variadic", name: decoded.name, fieldName };
}
return { kind: "metavar", name: decoded.name, fieldName };
}
// Legacy $ check
const childMeta = isMetavar(onlyChild.text);
if (childMeta !== null) {
metavars.push(childMeta);
return { kind: "metavar", name: childMeta, fieldName };
}
const childVariadic = isVariadic(onlyChild.text);
if (childVariadic !== null) {
if (childVariadic) metavars.push(childVariadic);
return { kind: "variadic", name: childVariadic, fieldName };
}
}
}
// Leaf node — literal match
if (node.namedChildCount === 0) {
return { kind: "literal", nodeType: node.type, text, children: [], fieldName };
}
// Branch node — recurse into named children
const children: PatternNode[] = [];
for (const child of node.namedChildren) {
const childFieldName = getFieldName(node, child);
children.push(buildPatternNode(child, metavars, childFieldName, placeholders));
}
return { kind: "literal", nodeType: node.type, children, fieldName };
}
/** Get the field name for a child node relative to its parent */
function getFieldName(parent: SyntaxNode, child: SyntaxNode): string | undefined {
// Walk parent's children to find the field name for this child
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c && c.id === child.id) {
return parent.fieldNameForChild(i) ?? undefined;
}
}
return undefined;
}
// ── Unwrap helpers ──────────────────────────────────────────────────────────
/** Unwrap from program → first meaningful child */
function unwrapProgram(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
const child = root.namedChildren[0];
// Unwrap expression_statement wrapper if present
if (child.type === "expression_statement" && child.namedChildCount === 1) {
return child.namedChildren[0];
}
return child;
}
/** Unwrap from module → first meaningful child (Python uses "module" as root) */
function unwrapModule(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "module" || root.namedChildCount === 0) return null;
const child = root.namedChildren[0];
if (child.type === "expression_statement" && child.namedChildCount === 1) {
return child.namedChildren[0];
}
return child;
}
/** Unwrap from program → expression_statement → parenthesized_expression → inner */
function unwrapExpression(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
let node = root.namedChildren[0];
if (node.type === "expression_statement") node = node.namedChildren[0];
if (node.type === "parenthesized_expression" && node.namedChildCount === 1) {
return node.namedChildren[0];
}
return node;
}
/** Unwrap from program → function_declaration → body → first statement */
function unwrapFunctionBody(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "program" || root.namedChildCount === 0) return null;
const fn = root.namedChildren[0];
if (!fn.type.includes("function")) return null;
const body = fn.childForFieldName("body");
if (!body || body.namedChildCount === 0) return null;
const stmt = body.namedChildren[0];
if (stmt.type === "expression_statement" && stmt.namedChildCount === 1) {
return stmt.namedChildren[0];
}
return stmt;
}
/** Unwrap from module → function_definition → body → first statement (Python) */
function unwrapPythonFunctionBody(root: SyntaxNode): SyntaxNode | null {
if (root.type !== "module" || root.namedChildCount === 0) return null;
const fn = root.namedChildren[0];
if (fn.type !== "function_definition") return null;
const body = fn.childForFieldName("body");
if (!body || body.namedChildCount === 0) return null;
const stmt = body.namedChildren[0];
if (stmt.type === "expression_statement" && stmt.namedChildCount === 1) {
return stmt.namedChildren[0];
}
return stmt;
}
/** Check if an AST node contains any ERROR nodes */
function hasErrors(node: SyntaxNode): boolean {
if (node.type === "ERROR" || node.isMissing) return true;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child && hasErrors(child)) return true;
}
return false;
}

View File

@@ -0,0 +1,138 @@
/**
* Rewrite Engine — apply structural replacements using matched patterns.
*
* Takes search matches and a replacement template with metavariable references,
* substitutes captured values, and applies the text changes.
*/
import { readFile, writeFile } from "node:fs/promises";
import type { SearchMatch } from "./search-engine.js";
// ── Result types ────────────────────────────────────────────────────────────
export interface RewriteChange {
file: string;
line: number;
column: number;
before: string;
after: string;
}
export interface RewriteResult {
changes: RewriteChange[];
filesModified: number;
}
// ── Replacement template substitution ───────────────────────────────────────
const METAVAR_REF_RE = /\$\$\$([A-Z_][A-Z0-9_]*)|\$([A-Z_][A-Z0-9_]*)/g;
/**
* Substitute metavariable references in a replacement template with captured values.
*/
export function substituteCaptures(
template: string,
captures: Record<string, string>,
): string {
return template.replace(METAVAR_REF_RE, (match, variadicName, singleName) => {
const name = variadicName ?? singleName;
if (name in captures) return captures[name];
return match; // Leave unmatched references as-is
});
}
/**
* If the original matched text ends with a semicolon (possibly preceded by whitespace)
* and the replacement doesn't, append the semicolon. This preserves statement terminators
* that are part of the AST node but not part of the pattern/replacement.
*/
function preserveTrailingSemicolon(original: string, replacement: string): string {
const trailingMatch = original.match(/(\s*;)\s*$/);
if (trailingMatch && !replacement.trimEnd().endsWith(";")) {
return replacement + trailingMatch[1];
}
return replacement;
}
// ── Rewrite application ─────────────────────────────────────────────────────
/**
* Compute rewrite changes from matches and a replacement template.
* Returns the list of changes without applying them.
*/
export function computeRewrites(
matches: SearchMatch[],
replacementTemplate: string,
): RewriteChange[] {
return matches.map((m) => {
const raw = substituteCaptures(replacementTemplate, m.captures);
const after = preserveTrailingSemicolon(m.matchedText, raw);
return {
file: m.file,
line: m.line,
column: m.column,
before: m.matchedText,
after,
};
});
}
/**
* Apply rewrite changes to files. Modifies files in-place.
* Applies changes bottom-up (last offset first) within each file to preserve byte offsets.
*/
export async function applyRewrites(
matches: SearchMatch[],
replacementTemplate: string,
): Promise<RewriteResult> {
// Group matches by file
const byFile = new Map<string, SearchMatch[]>();
for (const m of matches) {
const existing = byFile.get(m.file);
if (existing) {
existing.push(m);
} else {
byFile.set(m.file, [m]);
}
}
const changes: RewriteChange[] = [];
let filesModified = 0;
for (const [file, fileMatches] of byFile) {
// Sort by startIndex descending — apply from bottom to top
const sorted = [...fileMatches].sort((a, b) => b.startIndex - a.startIndex);
let content = await readFile(file, "utf-8");
let modified = false;
for (const m of sorted) {
const raw = substituteCaptures(replacementTemplate, m.captures);
const replacement = preserveTrailingSemicolon(m.matchedText, raw);
if (replacement !== m.matchedText) {
content =
content.slice(0, m.startIndex) +
replacement +
content.slice(m.endIndex);
modified = true;
}
changes.push({
file,
line: m.line,
column: m.column,
before: m.matchedText,
after: replacement,
});
}
if (modified) {
await writeFile(file, content, "utf-8");
filesModified++;
}
}
// Reverse so changes appear top-to-bottom
changes.reverse();
return { changes, filesModified };
}

View File

@@ -0,0 +1,359 @@
/**
* Search Engine — run compiled patterns against source files and collect matches.
*
* Uses a recursive AST matcher: the target file's tree-sitter AST is walked
* depth-first, and at each node we attempt to match the compiled pattern tree.
* Metavariable nodes capture any single AST node; variadic nodes capture
* zero-or-more siblings.
*/
import { resolve } from "node:path";
import { readdir, readFile, stat } from "node:fs/promises";
import type Parser from "web-tree-sitter";
import type { TreeSitterManager } from "./parser-manager.js";
import type {
CompiledPattern,
PatternNode,
} from "./pattern-compiler.js";
import { SKIP_DIRS, MAX_FILE_SIZE, MAX_INDEX_FILES } from "../shared/constants.js";
type SyntaxNode = Parser.SyntaxNode;
// ── Result types ────────────────────────────────────────────────────────────
export interface SearchMatch {
/** Absolute file path */
file: string;
/** 1-indexed line number */
line: number;
/** 1-indexed column */
column: number;
/** The matched source text */
matchedText: string;
/** Byte offset of match start */
startIndex: number;
/** Byte offset of match end */
endIndex: number;
/** Metavariable bindings: name → captured text */
captures: Record<string, string>;
}
// ── Directories to skip ─────────────────────────────────────────────────────
// Shared constants imported from ../shared/constants.ts
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Search files for matches against a compiled pattern.
*/
export async function searchFiles(
pattern: CompiledPattern,
rootDir: string,
treeSitter: TreeSitterManager,
options: {
path?: string;
maxResults?: number;
} = {},
): Promise<SearchMatch[]> {
const searchRoot = options.path ? resolve(rootDir, options.path) : rootDir;
const maxResults = options.maxResults ?? 50;
// Determine if searchRoot is a file or directory
const stats = await stat(searchRoot);
let files: string[];
if (stats.isFile()) {
files = [searchRoot];
} else {
files = await collectFilesByLanguage(searchRoot, pattern.languageId, treeSitter);
}
const matches: SearchMatch[] = [];
for (const file of files) {
if (matches.length >= maxResults) break;
try {
const content = await readFile(file, "utf-8");
const tree = await treeSitter.parseWithLanguage(file, content, pattern.languageId);
if (!tree) continue;
const fileMatches = findMatches(tree.rootNode, pattern.root);
for (const m of fileMatches) {
if (matches.length >= maxResults) break;
matches.push({
file,
line: m.node.startPosition.row + 1,
column: m.node.startPosition.column + 1,
matchedText: m.node.text,
startIndex: m.node.startIndex,
endIndex: m.node.endIndex,
captures: m.captures,
});
}
} catch {
// Skip unreadable files
}
}
return matches;
}
// ── File collection ─────────────────────────────────────────────────────────
/**
* Collect all files matching a given language under a directory.
*/
export async function collectFilesByLanguage(
dir: string,
languageId: string,
treeSitter: TreeSitterManager,
collected: string[] = [],
maxFiles: number = MAX_INDEX_FILES,
): Promise<string[]> {
if (collected.length >= maxFiles) return collected;
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (collected.length >= maxFiles) break;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await collectFilesByLanguage(resolve(dir, entry.name), languageId, treeSitter, collected, maxFiles);
} else if (entry.isFile()) {
const fileLang = treeSitter.getLanguageId(entry.name);
if (fileLang === languageId) {
const filePath = resolve(dir, entry.name);
try {
const s = await stat(filePath);
if (s.size <= MAX_FILE_SIZE) {
collected.push(filePath);
}
} catch {}
}
}
}
} catch {
// Permission denied or other IO error — skip
}
return collected;
}
// ── Matching engine ─────────────────────────────────────────────────────────
interface RawMatch {
node: SyntaxNode;
captures: Record<string, string>;
}
/**
* Find all non-overlapping matches of the pattern in the target AST.
* Walks the tree depth-first, attempting to match at each node.
*/
function findMatches(root: SyntaxNode, pattern: PatternNode): RawMatch[] {
const matches: RawMatch[] = [];
const visited = new Set<number>(); // node ids already part of a match
function walk(node: SyntaxNode): void {
if (visited.has(node.id)) return;
const captures: Record<string, string> = {};
if (matchNode(node, pattern, captures)) {
matches.push({ node, captures });
// Mark all descendant nodes as visited to prevent overlapping matches
markDescendants(node, visited);
return; // Don't recurse into matched subtree
}
// Recurse into children
for (const child of node.namedChildren) {
walk(child);
}
}
walk(root);
return matches;
}
function markDescendants(node: SyntaxNode, visited: Set<number>): void {
visited.add(node.id);
for (const child of node.namedChildren) {
markDescendants(child, visited);
}
}
/**
* Try to match a target AST node against a pattern node.
* Returns true if matched, populating `captures` with metavariable bindings.
*/
function matchNode(
target: SyntaxNode,
pattern: PatternNode,
captures: Record<string, string>,
): boolean {
switch (pattern.kind) {
case "metavar": {
// A metavar matches any single node
const name = pattern.name;
if (name in captures) {
// Already captured — must match same text
return captures[name] === target.text;
}
captures[name] = target.text;
return true;
}
case "variadic": {
// Variadic at the top level matches any single node
// (variadic is really only meaningful in a children-list context)
const name = pattern.name;
if (name && name in captures) {
return captures[name] === target.text;
}
if (name) captures[name] = target.text;
return true;
}
case "literal": {
// Must match node type
if (target.type !== pattern.nodeType) return false;
// Leaf node: must match text exactly
if (pattern.text !== undefined) {
return target.text === pattern.text;
}
// Branch node: use field-aware matching
// Pattern children that have field names → match against target children with same field name
// This allows the target to have extra children not mentioned in the pattern
return matchChildrenFieldAware(target, pattern.children, captures);
}
}
}
/**
* Match pattern children against target node's children using field-name-aware matching.
*
* Strategy:
* 1. Pattern children WITH field names: find the target child with the same field name and match
* 2. Pattern children WITHOUT field names: match positionally against unmatched target children
* 3. Extra target children not mentioned in the pattern are allowed (implicit wildcards)
*/
function matchChildrenFieldAware(
targetNode: SyntaxNode,
patternChildren: PatternNode[],
captures: Record<string, string>,
): boolean {
// Separate pattern children into field-named and positional
const fieldPatterns: PatternNode[] = [];
const positionalPatterns: PatternNode[] = [];
for (const pc of patternChildren) {
if (pc.fieldName) {
fieldPatterns.push(pc);
} else {
positionalPatterns.push(pc);
}
}
// First, match all field-named pattern children
for (const fp of fieldPatterns) {
const targetChild = targetNode.childForFieldName(fp.fieldName!);
if (!targetChild) return false;
if (!matchNode(targetChild, fp, captures)) return false;
}
// If there are positional patterns, match them against the remaining unnamed target children
if (positionalPatterns.length > 0) {
// Get target children that aren't already matched by field names
const matchedFieldNames = new Set(fieldPatterns.map(fp => fp.fieldName));
const remainingTargets: SyntaxNode[] = [];
for (const child of targetNode.namedChildren) {
const childField = getChildFieldName(targetNode, child);
if (!childField || !matchedFieldNames.has(childField)) {
remainingTargets.push(child);
}
}
return matchChildrenPositional(remainingTargets, 0, positionalPatterns, 0, captures);
}
return true;
}
/** Get the field name of a child node within its parent */
function getChildFieldName(parent: SyntaxNode, child: SyntaxNode): string | null {
for (let i = 0; i < parent.childCount; i++) {
const c = parent.child(i);
if (c && c.id === child.id) {
return parent.fieldNameForChild(i);
}
}
return null;
}
/**
* Match pattern children positionally against target children.
* Handles variadic patterns that can match zero or more consecutive children.
* Extra target children at the end are allowed (pattern doesn't need to cover all children).
*/
function matchChildrenPositional(
targets: SyntaxNode[],
ti: number,
patterns: PatternNode[],
pi: number,
captures: Record<string, string>,
): boolean {
// All patterns consumed — success (extra targets are OK)
if (pi >= patterns.length) return true;
const pat = patterns[pi];
// Variadic pattern: try matching 0, 1, 2, ... consecutive target nodes
if (pat.kind === "variadic") {
const isLast = pi === patterns.length - 1;
// Optimization: if this is the last pattern, consume all remaining
if (isLast) {
const remainingText = targets.slice(ti).map(t => t.text).join(", ");
if (pat.name) {
if (pat.name in captures && captures[pat.name] !== remainingText) return false;
captures[pat.name] = remainingText;
}
return true;
}
// Try consuming 0..N target nodes
for (let take = 0; take <= targets.length - ti; take++) {
const captureSnapshot = { ...captures };
const consumedText = targets.slice(ti, ti + take).map(t => t.text).join(", ");
if (pat.name) {
if (pat.name in captureSnapshot && captureSnapshot[pat.name] !== consumedText) continue;
captureSnapshot[pat.name] = consumedText;
}
if (matchChildrenPositional(targets, ti + take, patterns, pi + 1, captureSnapshot)) {
// Success — commit captures
Object.assign(captures, captureSnapshot);
return true;
}
}
return false;
}
// Non-variadic pattern: must match the current target
if (ti >= targets.length) return false;
const captureSnapshot = { ...captures };
if (matchNode(targets[ti], pat, captureSnapshot)) {
if (matchChildrenPositional(targets, ti + 1, patterns, pi + 1, captureSnapshot)) {
Object.assign(captures, captureSnapshot);
return true;
}
}
return false;
}

View File

@@ -0,0 +1,398 @@
/**
* Symbol Extractor — extracts symbols from tree-sitter ASTs.
*
* Per-language node type mappings for functions, classes, methods, interfaces, etc.
* Output format matches LSP DocumentSymbol shape for compatibility.
*/
import type Parser from "web-tree-sitter";
type Tree = Parser.Tree;
type Node = Parser.SyntaxNode;
/** Symbol kinds matching LSP SymbolKind values */
export const SymbolKind = {
File: 1, Module: 2, Namespace: 3, Package: 4, Class: 5, Method: 6,
Property: 7, Field: 8, Constructor: 9, Enum: 10, Interface: 11,
Function: 12, Variable: 13, Constant: 14, String: 15, Number: 16,
Boolean: 17, Array: 18, Object: 19, Struct: 22,
} as const;
export type SymbolKindValue = typeof SymbolKind[keyof typeof SymbolKind];
export interface SymbolInfo {
name: string;
kind: SymbolKindValue;
line: number; // 1-indexed
endLine: number; // 1-indexed
children?: SymbolInfo[];
}
/** Node types that represent symbol declarations, per language family */
interface SymbolMapping {
nodeType: string;
kind: SymbolKindValue;
/** Field name to extract the symbol name from (default: "name") */
nameField?: string;
/** Whether to recurse into children for nested symbols */
recurse?: boolean;
}
const TS_JS_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_declaration", kind: SymbolKind.Function },
{ nodeType: "class_declaration", kind: SymbolKind.Class, recurse: true },
{ nodeType: "interface_declaration", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "enum_declaration", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "type_alias_declaration", kind: SymbolKind.Variable },
{ nodeType: "method_definition", kind: SymbolKind.Method },
{ nodeType: "public_field_definition", kind: SymbolKind.Field },
{ nodeType: "abstract_method_signature", kind: SymbolKind.Method },
{ nodeType: "lexical_declaration", kind: SymbolKind.Variable },
{ nodeType: "variable_declaration", kind: SymbolKind.Variable },
];
const PYTHON_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_definition", kind: SymbolKind.Function },
{ nodeType: "class_definition", kind: SymbolKind.Class, recurse: true },
{ nodeType: "decorated_definition", kind: SymbolKind.Function },
];
const RUST_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_item", kind: SymbolKind.Function },
{ nodeType: "struct_item", kind: SymbolKind.Struct },
{ nodeType: "enum_item", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "impl_item", kind: SymbolKind.Class, recurse: true },
{ nodeType: "trait_item", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "mod_item", kind: SymbolKind.Module, recurse: true },
{ nodeType: "type_item", kind: SymbolKind.Variable },
{ nodeType: "const_item", kind: SymbolKind.Constant },
{ nodeType: "static_item", kind: SymbolKind.Constant },
{ nodeType: "macro_definition", kind: SymbolKind.Function },
];
const GO_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_declaration", kind: SymbolKind.Function },
{ nodeType: "method_declaration", kind: SymbolKind.Method },
{ nodeType: "type_declaration", kind: SymbolKind.Class },
{ nodeType: "type_spec", kind: SymbolKind.Class },
{ nodeType: "const_declaration", kind: SymbolKind.Constant },
{ nodeType: "var_declaration", kind: SymbolKind.Variable },
];
const JAVA_SYMBOLS: SymbolMapping[] = [
{ nodeType: "class_declaration", kind: SymbolKind.Class, recurse: true },
{ nodeType: "interface_declaration", kind: SymbolKind.Interface, recurse: true },
{ nodeType: "enum_declaration", kind: SymbolKind.Enum, recurse: true },
{ nodeType: "method_declaration", kind: SymbolKind.Method },
{ nodeType: "constructor_declaration", kind: SymbolKind.Constructor },
{ nodeType: "field_declaration", kind: SymbolKind.Field },
{ nodeType: "annotation_type_declaration", kind: SymbolKind.Interface },
];
const C_CPP_SYMBOLS: SymbolMapping[] = [
{ nodeType: "function_definition", kind: SymbolKind.Function },
{ nodeType: "declaration", kind: SymbolKind.Variable },
{ nodeType: "struct_specifier", kind: SymbolKind.Struct },
{ nodeType: "enum_specifier", kind: SymbolKind.Enum },
{ nodeType: "class_specifier", kind: SymbolKind.Class, recurse: true },
{ nodeType: "namespace_definition", kind: SymbolKind.Namespace, recurse: true },
];
const RUBY_SYMBOLS: SymbolMapping[] = [
{ nodeType: "method", kind: SymbolKind.Method },
{ nodeType: "singleton_method", kind: SymbolKind.Method },
{ nodeType: "class", kind: SymbolKind.Class, recurse: true },
{ nodeType: "module", kind: SymbolKind.Module, recurse: true },
];
const LANGUAGE_SYMBOLS: Record<string, SymbolMapping[]> = {
typescript: TS_JS_SYMBOLS,
typescriptreact: TS_JS_SYMBOLS,
javascript: TS_JS_SYMBOLS,
javascriptreact: TS_JS_SYMBOLS,
python: PYTHON_SYMBOLS,
rust: RUST_SYMBOLS,
go: GO_SYMBOLS,
java: JAVA_SYMBOLS,
c: C_CPP_SYMBOLS,
cpp: C_CPP_SYMBOLS,
ruby: RUBY_SYMBOLS,
};
/**
* Extract symbols from a parsed tree.
*/
export function extractSymbols(tree: Tree, languageId: string): SymbolInfo[] {
const mappings = LANGUAGE_SYMBOLS[languageId];
if (!mappings) return extractGenericSymbols(tree);
return extractFromNode(tree.rootNode, mappings, languageId);
}
function extractFromNode(node: Node, mappings: SymbolMapping[], languageId: string, deep = false): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
for (const child of node.namedChildren) {
const mapping = mappings.find((m) => m.nodeType === child.type);
if (mapping) {
const name = extractName(child, mapping, languageId);
if (name) {
const sym: SymbolInfo = {
name,
kind: mapping.kind,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
};
if (mapping.recurse) {
const children = extractFromNode(child, mappings, languageId, true);
if (children.length > 0) sym.children = children;
}
symbols.push(sym);
}
} else if (child.type === "export_statement" && (languageId.startsWith("typescript") || languageId.startsWith("javascript"))) {
// Unwrap export statements to find the actual declaration
const inner = extractFromNode(child, mappings, languageId, deep);
symbols.push(...inner);
} else if (deep && child.namedChildCount > 0) {
// Walk through container nodes (class_body, interface_body, etc.)
// to find nested declarations when recursing inside a parent
symbols.push(...extractFromNode(child, mappings, languageId, true));
}
}
return symbols;
}
/** Extract the name of a symbol from a node */
function extractName(node: Node, mapping: SymbolMapping, languageId: string): string | null {
// Try the "name" field first (works for most declarations)
const nameNode = node.childForFieldName(mapping.nameField ?? "name");
if (nameNode) return nameNode.text;
// Language-specific fallbacks
switch (node.type) {
case "lexical_declaration":
case "variable_declaration": {
// const foo = ..., let bar = ...
const declarator = node.namedChildren.find(
(c) => c.type === "variable_declarator" || c.type === "init_declarator"
);
if (declarator) {
const n = declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "decorated_definition": {
// Python: @decorator \n def foo(): ...
const inner = node.namedChildren.find(
(c) => c.type === "function_definition" || c.type === "class_definition"
);
if (inner) {
const n = inner.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "impl_item": {
// Rust: impl Foo { ... } or impl Trait for Foo { ... }
const typeNode = node.childForFieldName("type");
if (typeNode) return `impl ${typeNode.text}`;
return null;
}
case "type_declaration": {
// Go: type Foo struct { ... }
const spec = node.namedChildren.find((c) => c.type === "type_spec");
if (spec) {
const n = spec.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "const_declaration":
case "var_declaration": {
// Go: const/var declarations
const spec = node.namedChildren.find(
(c) => c.type === "const_spec" || c.type === "var_spec"
);
if (spec) {
const n = spec.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "field_declaration": {
// Java: field declarations
const declarator = node.namedChildren.find((c) => c.type === "variable_declarator");
if (declarator) {
const n = declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
case "declaration": {
// C/C++: declarations
const declarator = node.namedChildren.find(
(c) => c.type === "init_declarator" || c.type === "function_declarator"
);
if (declarator) {
const n = declarator.childForFieldName("declarator") ?? declarator.childForFieldName("name");
return n?.text ?? null;
}
return null;
}
default:
break;
}
// Last resort: first named child's text (truncated)
const first = node.firstNamedChild;
if (first && first.text.length < 60) return first.text;
return null;
}
/** Generic fallback for languages without specific mappings */
function extractGenericSymbols(tree: Tree): SymbolInfo[] {
const symbols: SymbolInfo[] = [];
const root = tree.rootNode;
for (const child of root.namedChildren) {
// Look for common declaration patterns
if (child.type.includes("function") || child.type.includes("method")) {
const name = child.childForFieldName("name")?.text;
if (name) {
symbols.push({
name,
kind: SymbolKind.Function,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
});
}
} else if (child.type.includes("class") || child.type.includes("struct")) {
const name = child.childForFieldName("name")?.text;
if (name) {
symbols.push({
name,
kind: SymbolKind.Class,
line: child.startPosition.row + 1,
endLine: child.endPosition.row + 1,
});
}
}
}
return symbols;
}
/**
* Get the named node at a specific position in the tree.
* Returns the smallest named node that contains the position.
*/
export function getNodeAtPosition(tree: Tree, line: number, character: number): Node | null {
// tree-sitter uses 0-indexed positions
const point = { row: line, column: character };
return tree.rootNode.namedDescendantForPosition(point);
}
/**
* Find definition(s) of a symbol name within a tree.
* Searches for declaration nodes whose name matches.
*/
export function findDefinition(tree: Tree, symbolName: string, languageId: string): SymbolInfo[] {
const allSymbols = extractSymbols(tree, languageId);
return findSymbolByName(allSymbols, symbolName);
}
function findSymbolByName(symbols: SymbolInfo[], name: string): SymbolInfo[] {
const results: SymbolInfo[] = [];
for (const sym of symbols) {
if (sym.name === name) results.push(sym);
if (sym.children) results.push(...findSymbolByName(sym.children, name));
}
return results;
}
/**
* Get syntax errors from a parsed tree.
* Returns ERROR and MISSING nodes as diagnostic-like objects.
*/
export function getSyntaxErrors(tree: Tree): Array<{
line: number;
character: number;
endLine: number;
endCharacter: number;
message: string;
}> {
const errors: Array<{
line: number;
character: number;
endLine: number;
endCharacter: number;
message: string;
}> = [];
function walk(node: Node): void {
if (node.isError) {
errors.push({
line: node.startPosition.row,
character: node.startPosition.column,
endLine: node.endPosition.row,
endCharacter: node.endPosition.column,
message: `Syntax error: unexpected "${node.text.slice(0, 50)}${node.text.length > 50 ? "..." : ""}"`,
});
} else if (node.isMissing) {
errors.push({
line: node.startPosition.row,
character: node.startPosition.column,
endLine: node.endPosition.row,
endCharacter: node.endPosition.column,
message: `Missing ${node.type}`,
});
} else if (node.hasError) {
// Recurse into children only if this node contains errors
for (const child of node.children) {
walk(child);
}
}
}
walk(tree.rootNode);
return errors;
}
/**
* Get the signature text for a node (for hover info).
* Extracts the first line of the declaration.
*/
export function getSignatureText(node: Node): string {
const text = node.text;
const firstLine = text.split("\n")[0];
// Trim trailing { or : for cleaner display
return firstLine.replace(/\s*[{:]\s*$/, "").trim();
}
/**
* Get the enclosing declaration node for a position.
* Walks up from the node at position to find the nearest declaration.
*/
export function getEnclosingDeclaration(tree: Tree, line: number, character: number): Node | null {
const point = { row: line, column: character };
let node: Node | null = tree.rootNode.namedDescendantForPosition(point);
const declarationTypes = new Set([
"function_declaration", "function_definition", "function_item",
"method_declaration", "method_definition",
"class_declaration", "class_definition", "class_specifier",
"interface_declaration", "enum_declaration", "enum_item",
"struct_item", "impl_item", "trait_item", "mod_item",
"type_alias_declaration", "type_declaration",
"variable_declarator", "lexical_declaration",
"const_item", "static_item",
"decorated_definition",
]);
while (node) {
if (declarationTypes.has(node.type)) return node;
node = node.parent;
}
return null;
}

View File

@@ -0,0 +1,237 @@
/**
* Workspace Index — project-wide symbol index with incremental updates.
*
* Walks the project tree, parses files with tree-sitter, and maintains
* an in-memory symbol index keyed by name for fast lookup.
*/
import { resolve } from "node:path";
import { readdir, stat, readFile } from "node:fs/promises";
import { TreeSitterManager } from "./parser-manager.js";
import { extractSymbols, type SymbolInfo, type SymbolKindValue } from "./symbol-extractor.js";
import { SKIP_DIRS, MAX_FILE_SIZE, MAX_INDEX_FILES } from "../shared/constants.js";
export interface SymbolEntry {
name: string;
kind: SymbolKindValue;
file: string; // absolute path
line: number; // 1-indexed
}
// Shared constants imported from ../shared/constants.ts
export class WorkspaceIndex {
/** Map from symbol name (lowercase) to entries */
private index: Map<string, SymbolEntry[]> = new Map();
/** Reverse index: file path → set of index keys that have entries for this file */
private fileToKeys: Map<string, Set<string>> = new Map();
/** Set of indexed file paths (absolute) */
private indexedFiles: Set<string> = new Set();
/** Whether the initial build has completed */
private built = false;
private building: Promise<void> | null = null;
constructor(
private rootDir: string,
private treeSitter: TreeSitterManager,
) {}
/** Build the index by walking the project tree. Deduplicates concurrent calls. */
async build(): Promise<void> {
if (this.built) return;
if (this.building) {
await this.building;
return;
}
this.building = this._build();
await this.building;
this.built = true;
this.building = null;
}
private async _build(): Promise<void> {
const files = await this.collectFiles(this.rootDir);
// Parse and index each file
const batchSize = 50;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((f) => this.indexFile(f).catch(() => {})));
}
}
/** Collect all indexable files under a directory */
private async collectFiles(dir: string, collected: string[] = []): Promise<string[]> {
if (collected.length >= MAX_INDEX_FILES) return collected;
try {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (collected.length >= MAX_INDEX_FILES) break;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
await this.collectFiles(resolve(dir, entry.name), collected);
} else if (entry.isFile()) {
const languageId = this.treeSitter.getLanguageId(entry.name);
if (languageId && this.treeSitter.hasGrammar(languageId)) {
collected.push(resolve(dir, entry.name));
}
}
}
} catch {
// Permission denied or other IO error — skip
}
return collected;
}
/** Index (or re-index) a single file */
async indexFile(filePath: string): Promise<void> {
const absPath = resolve(filePath);
// Remove old entries for this file
this.removeFile(absPath);
try {
const stats = await stat(absPath);
if (stats.size > MAX_FILE_SIZE) return;
const content = await readFile(absPath, "utf-8");
const languageId = this.treeSitter.getLanguageId(absPath);
if (!languageId) return;
const tree = await this.treeSitter.parse(absPath, content);
if (!tree) return;
const symbols = extractSymbols(tree, languageId);
this.addSymbols(absPath, symbols);
this.indexedFiles.add(absPath);
} catch {
// File might not exist or be unreadable
}
}
/** Index a file from already-available content (avoids re-reading) */
async indexFileContent(filePath: string, content: string): Promise<void> {
const absPath = resolve(filePath);
this.removeFile(absPath);
const languageId = this.treeSitter.getLanguageId(absPath);
if (!languageId) return;
const tree = await this.treeSitter.parse(absPath, content);
if (!tree) return;
const symbols = extractSymbols(tree, languageId);
this.addSymbols(absPath, symbols);
this.indexedFiles.add(absPath);
}
private addSymbols(filePath: string, symbols: SymbolInfo[]): void {
for (const sym of symbols) {
const entry: SymbolEntry = {
name: sym.name,
kind: sym.kind,
file: filePath,
line: sym.line,
};
const key = sym.name.toLowerCase();
const existing = this.index.get(key);
if (existing) {
existing.push(entry);
} else {
this.index.set(key, [entry]);
}
// Track in reverse index for fast removal
let keys = this.fileToKeys.get(filePath);
if (!keys) {
keys = new Set();
this.fileToKeys.set(filePath, keys);
}
keys.add(key);
// Recurse into children
if (sym.children) {
this.addSymbols(filePath, sym.children);
}
}
}
/** Remove all entries for a file (O(keys-per-file) via reverse index) */
removeFile(filePath: string): void {
const absPath = resolve(filePath);
this.indexedFiles.delete(absPath);
// Use reverse index for targeted removal
const keys = this.fileToKeys.get(absPath);
if (keys) {
for (const key of keys) {
const entries = this.index.get(key);
if (!entries) continue;
const filtered = entries.filter((e) => e.file !== absPath);
if (filtered.length === 0) {
this.index.delete(key);
} else {
this.index.set(key, filtered);
}
}
this.fileToKeys.delete(absPath);
}
}
/** Search for symbols matching a query (fuzzy) */
search(query: string): SymbolEntry[] {
if (!query) return [];
const queryLower = query.toLowerCase();
// Exact match first
const exact = this.index.get(queryLower) ?? [];
// Prefix/substring matches
const fuzzy: SymbolEntry[] = [];
for (const [key, entries] of this.index) {
if (key === queryLower) continue; // already included
if (key.includes(queryLower)) {
fuzzy.push(...entries);
}
}
// Sort: exact matches first, then by name length (shorter = more relevant)
const results = [...exact, ...fuzzy];
results.sort((a, b) => {
const aExact = a.name.toLowerCase() === queryLower ? 0 : 1;
const bExact = b.name.toLowerCase() === queryLower ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
return a.name.length - b.name.length;
});
return results.slice(0, 100); // Cap results
}
/** Get all symbols for a specific file */
getSymbolsForFile(filePath: string): SymbolEntry[] {
const absPath = resolve(filePath);
const results: SymbolEntry[] = [];
for (const entries of this.index.values()) {
for (const entry of entries) {
if (entry.file === absPath) results.push(entry);
}
}
return results.sort((a, b) => a.line - b.line);
}
/** Get index stats */
getStats(): { files: number; symbols: number } {
let symbolCount = 0;
for (const entries of this.index.values()) {
symbolCount += entries.length;
}
return { files: this.indexedFiles.size, symbols: symbolCount };
}
/** Whether the index has been built */
get isBuilt(): boolean {
return this.built;
}
}

View File

@@ -0,0 +1,56 @@
/**
* Workspace Provider — abstraction for workspace detection and configuration.
*
* Separates workspace-specific concerns (root detection, multi-root folders,
* state directories) from the LSP manager.
*
* External extensions can provide custom implementations via pi.events:
* pi.events.emit("lsp:workspace-provider", myProvider);
*/
export interface WorkspaceProvider {
/** Provider type identifier */
readonly type: string;
/** Workspace root directory (if detected — may differ from cwd) */
readonly workspaceRoot: string | null;
/** Directory for persistent state (daemon sockets, PIDs, locks). Null disables daemon mode. */
readonly stateDir: string | null;
/** Get workspace folders for multi-root LSP initialization */
getWorkspaceFolders(): { uri: string; name: string }[];
/** One-time setup before first LSP server start. Returns true if ready. */
ensureReady(sessionId?: string): Promise<boolean>;
/** Human-readable status for UI */
getStatusText(): string;
/** Clean up resources */
shutdown(): void;
}
/**
* Default provider for standard workspaces.
* No special workspace detection, no daemon support, no multi-root.
*/
export class DefaultWorkspaceProvider implements WorkspaceProvider {
readonly type = "default";
readonly workspaceRoot = null;
readonly stateDir = null;
getWorkspaceFolders(): { uri: string; name: string }[] {
return [];
}
async ensureReady(): Promise<boolean> {
return true;
}
getStatusText(): string {
return "";
}
shutdown(): void {}
}

View File

@@ -0,0 +1,296 @@
/**
* Tests for structural search & rewrite: pattern-compiler, search-engine, rewrite-engine.
*
* Run: npx tsx test-structural-search.ts
*/
import { TreeSitterManager } from "./src/tree-sitter/parser-manager.js";
import { compilePattern, type CompiledPattern } from "./src/tree-sitter/pattern-compiler.js";
import { searchFiles, collectFilesByLanguage } from "./src/tree-sitter/search-engine.js";
import { computeRewrites, applyRewrites, substituteCaptures } from "./src/tree-sitter/rewrite-engine.js";
import { writeFileSync, mkdirSync, rmSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
let passed = 0;
let failed = 0;
function assert(condition: boolean, label: string) {
if (condition) {
console.log(`${label}`);
passed++;
} else {
console.log(`${label}`);
failed++;
}
}
const TEST_DIR = resolve("/tmp/structural-search-test");
async function main() {
const mgr = new TreeSitterManager();
await mgr.init();
// Set up test fixtures
try { rmSync(TEST_DIR, { recursive: true }); } catch {}
mkdirSync(resolve(TEST_DIR, "src"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "src/app.ts"), `
import { readFile } from "fs";
function processData(data: string): string {
console.log("processing", data);
const result = data.trim();
console.log("done");
return result;
}
function helper() {
var x = 10;
var y = "hello";
const z = 42;
}
class UserService {
async getUser(id: string) {
const user = await this.db.find(id);
return user;
}
async deleteUser(id: string) {
console.log("deleting", id);
await this.db.remove(id);
}
}
`);
writeFileSync(resolve(TEST_DIR, "src/utils.ts"), `
export function formatDate(d: Date): string {
console.log("formatting date");
return d.toISOString();
}
export function parseDate(s: string): Date {
return new Date(s);
}
export const doSomething = (a: number, b: number) => {
var result = a + b;
return result;
};
`);
// ── 1. Pattern compiler ──
console.log("\n🔧 Pattern Compiler");
// Simple identifier pattern
const p1 = await compilePattern("console.log($ARG)", "typescript", mgr);
assert(p1.root.kind === "literal", "console.log($ARG) compiles to literal root");
assert(p1.metavars.includes("ARG"), "captures $ARG metavar");
assert(p1.languageId === "typescript", "language is typescript");
// Variadic pattern
const p2 = await compilePattern("console.log($$$ARGS)", "typescript", mgr);
assert(p2.metavars.includes("ARGS"), "captures $$$ARGS variadic metavar");
// Variable declaration pattern
const p3 = await compilePattern("var $N = $V", "typescript", mgr);
assert(p3.metavars.includes("N"), "captures $N");
assert(p3.metavars.includes("V"), "captures $V");
// Pattern with no metavars
const p4 = await compilePattern("return result", "typescript", mgr);
assert(p4.metavars.length === 0, "no metavars in literal pattern");
// Bad pattern should throw
let threw = false;
try {
await compilePattern("{{{{invalid}}}", "typescript", mgr);
} catch {
threw = true;
}
assert(threw, "invalid pattern throws error");
// ── 2. Search engine — basic matching ──
console.log("\n🔍 Search Engine — basic matching");
// Find console.log calls with single arg
const compiled1 = await compilePattern("console.log($ARG)", "typescript", mgr);
const matches1 = await searchFiles(compiled1, TEST_DIR, mgr);
console.log(` Found ${matches1.length} console.log($ARG) matches`);
assert(matches1.length > 0, "found console.log($ARG) matches");
// Check captures
const firstMatch = matches1[0];
assert("ARG" in firstMatch.captures, "match has $ARG capture");
console.log(` First $ARG = "${firstMatch.captures.ARG}"`);
// Find var declarations
const compiled3 = await compilePattern("var $N = $V", "typescript", mgr);
const matches3 = await searchFiles(compiled3, TEST_DIR, mgr);
console.log(` Found ${matches3.length} var $N = $V matches`);
assert(matches3.length >= 3, "found at least 3 var declarations");
// Check that captures are different
const varNames = matches3.map(m => m.captures.N);
console.log(` Variable names: ${varNames.join(", ")}`);
assert(varNames.includes("x"), 'found var x');
assert(varNames.includes("y"), 'found var y');
// ── 3. Search engine — variadic matching ──
console.log("\n🔍 Search Engine — variadic matching");
// Find console.log with any number of args
const compiled2 = await compilePattern("console.log($$$ARGS)", "typescript", mgr);
const matches2 = await searchFiles(compiled2, TEST_DIR, mgr);
console.log(` Found ${matches2.length} console.log($$$ARGS) matches`);
assert(matches2.length >= 4, "found variadic console.log matches (at least 4)");
// ── 4. Search engine — file scoping ──
console.log("\n🔍 Search Engine — file scoping");
const scopedMatches = await searchFiles(compiled1, TEST_DIR, mgr, {
path: "src/utils.ts",
});
console.log(` Found ${scopedMatches.length} matches in utils.ts only`);
assert(scopedMatches.length >= 1, "found matches in scoped file");
assert(scopedMatches.every(m => m.file.endsWith("utils.ts")), "all matches in utils.ts");
// ── 5. Search engine — max results ──
console.log("\n🔍 Search Engine — max results");
const limitedMatches = await searchFiles(compiled2, TEST_DIR, mgr, { maxResults: 2 });
assert(limitedMatches.length <= 2, `max_results respected (got ${limitedMatches.length})`);
// ── 6. File collection ──
console.log("\n📂 File Collection");
const tsFiles = await collectFilesByLanguage(TEST_DIR, "typescript", mgr);
assert(tsFiles.length === 2, `found 2 TypeScript files (got ${tsFiles.length})`);
assert(tsFiles.some(f => f.endsWith("app.ts")), "includes app.ts");
assert(tsFiles.some(f => f.endsWith("utils.ts")), "includes utils.ts");
// Skips node_modules
mkdirSync(resolve(TEST_DIR, "node_modules/pkg"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "node_modules/pkg/index.ts"), "export const x = 1;");
const tsFiles2 = await collectFilesByLanguage(TEST_DIR, "typescript", mgr);
assert(tsFiles2.length === 2, `still 2 files (node_modules skipped, got ${tsFiles2.length})`);
// ── 7. Rewrite engine — substituteCaptures ──
console.log("\n✏ Rewrite Engine — substituteCaptures");
assert(
substituteCaptures("const $N = $V", { N: "x", V: "42" }) === "const x = 42",
"basic substitution works",
);
assert(
substituteCaptures("$$$ARGS", { ARGS: "a, b, c" }) === "a, b, c",
"variadic substitution works",
);
assert(
substituteCaptures("$UNKNOWN stays", {}) === "$UNKNOWN stays",
"unknown metavar left as-is",
);
// ── 8. Rewrite engine — computeRewrites (dry run) ──
console.log("\n✏ Rewrite Engine — computeRewrites (dry run)");
const varMatches = await searchFiles(compiled3, TEST_DIR, mgr);
const rewrites = computeRewrites(varMatches, "const $N = $V");
assert(rewrites.length === varMatches.length, "one rewrite per match");
for (const r of rewrites) {
assert(r.before.startsWith("var "), `before starts with "var" (got: ${r.before})`);
assert(r.after.startsWith("const "), `after starts with "const" (got: ${r.after})`);
console.log(` ${r.file.split("/").pop()}:${r.line}${r.before}${r.after}`);
}
// ── 9. Rewrite engine — applyRewrites ──
console.log("\n✏ Rewrite Engine — applyRewrites");
// Work on a copy to not mess up other tests
const rewriteDir = resolve(TEST_DIR, "rewrite-test");
mkdirSync(rewriteDir, { recursive: true });
writeFileSync(resolve(rewriteDir, "test.ts"), `
function example() {
var a = 1;
var b = "hello";
const c = true;
var d = [];
}
`);
const varPattern = await compilePattern("var $N = $V", "typescript", mgr);
const varResults = await searchFiles(varPattern, rewriteDir, mgr);
console.log(` Found ${varResults.length} var declarations to rewrite`);
assert(varResults.length === 3, `found 3 var declarations (got ${varResults.length})`);
const result = await applyRewrites(varResults, "const $N = $V");
assert(result.filesModified === 1, `modified 1 file (got ${result.filesModified})`);
assert(result.changes.length === 3, `3 changes (got ${result.changes.length})`);
// Verify the file was actually rewritten
const rewritten = readFileSync(resolve(rewriteDir, "test.ts"), "utf-8");
assert(!rewritten.includes("var a"), "var a replaced");
assert(!rewritten.includes("var b"), "var b replaced");
assert(!rewritten.includes("var d"), "var d replaced");
assert(rewritten.includes("const a = 1"), "const a = 1 present");
assert(rewritten.includes('const b = "hello"'), 'const b = "hello" present');
assert(rewritten.includes("const d = []"), "const d = [] present");
assert(rewritten.includes("const c = true"), "original const c preserved");
console.log(" Rewritten file content:");
console.log(rewritten.split("\n").map(l => ` ${l}`).join("\n"));
// ── 10. Rewrite — no-op when before === after ──
console.log("\n✏ Rewrite Engine — no-op detection");
const constPattern = await compilePattern("const $N = $V", "typescript", mgr);
const constMatches = await searchFiles(constPattern, rewriteDir, mgr);
const noopResult = await applyRewrites(constMatches, "const $N = $V");
assert(noopResult.filesModified === 0, "no files modified when replacement matches original");
// ── 11. Cross-language — Python ──
console.log("\n🐍 Cross-language — Python");
mkdirSync(resolve(TEST_DIR, "py"), { recursive: true });
writeFileSync(resolve(TEST_DIR, "py/main.py"), `
def process(data):
print("start")
result = data.strip()
print("done")
return result
def helper():
print("helper called")
`);
const pyPattern = await compilePattern("print($ARG)", "python", mgr);
const pyMatches = await searchFiles(pyPattern, TEST_DIR, mgr, { path: "py" });
console.log(` Found ${pyMatches.length} print($ARG) matches in Python`);
assert(pyMatches.length >= 3, "found Python print matches");
assert(pyMatches.every(m => m.file.endsWith(".py")), "all matches are Python files");
// ── 12. Search — return value structure ──
console.log("\n📋 Search — result structure");
const singleMatch = (await searchFiles(compiled1, TEST_DIR, mgr, { maxResults: 1 }))[0];
assert(typeof singleMatch.file === "string", "match.file is string");
assert(typeof singleMatch.line === "number" && singleMatch.line > 0, "match.line is positive number");
assert(typeof singleMatch.column === "number" && singleMatch.column > 0, "match.column is positive number");
assert(typeof singleMatch.matchedText === "string", "match.matchedText is string");
assert(typeof singleMatch.startIndex === "number", "match.startIndex exists");
assert(typeof singleMatch.endIndex === "number", "match.endIndex exists");
assert(singleMatch.endIndex > singleMatch.startIndex, "endIndex > startIndex");
assert(typeof singleMatch.captures === "object", "match.captures is object");
// ── Cleanup ──
try { rmSync(TEST_DIR, { recursive: true }); } catch {}
mgr.shutdown();
// ── Summary ──
console.log(`\n${"═".repeat(50)}`);
console.log(` ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(50)}\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,397 @@
/**
* Quick smoke test for the tree-sitter integration.
* Exercises: parser-manager, symbol-extractor, workspace-index
*
* Run: npx tsx test-tree-sitter.ts
*/
import { TreeSitterManager } from "./src/tree-sitter/parser-manager.js";
import { extractSymbols, getNodeAtPosition, findDefinition, getSyntaxErrors, getSignatureText, getEnclosingDeclaration } from "./src/tree-sitter/symbol-extractor.js";
import { WorkspaceIndex } from "./src/tree-sitter/workspace-index.js";
import { writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function assert(condition: boolean, label: string) {
if (condition) {
console.log(`${label}`);
passed++;
} else {
console.log(`${label}`);
failed++;
}
}
async function main() {
const mgr = new TreeSitterManager();
// ── 1. Init & language detection ──
console.log("\n🔧 Parser Manager — init & language detection");
await mgr.init();
assert(mgr.getLanguageId("foo.ts") === "typescript", "foo.ts → typescript");
assert(mgr.getLanguageId("bar.py") === "python", "bar.py → python");
assert(mgr.getLanguageId("baz.rs") === "rust", "baz.rs → rust");
assert(mgr.getLanguageId("main.go") === "go", "main.go → go");
assert(mgr.getLanguageId("App.java") === "java", "App.java → java");
assert(mgr.getLanguageId("README.md") === undefined, "README.md → undefined");
assert(mgr.hasGrammar("typescript"), "has grammar: typescript");
assert(!mgr.hasGrammar("haskell"), "no grammar: haskell");
assert(mgr.getSupportedLanguages().length >= 19, `supported languages >= 19 (got ${mgr.getSupportedLanguages().length})`);
// ── 2. Parse TypeScript ──
console.log("\n🌳 Parse TypeScript");
const tsCode = `
export function greet(name: string): string {
return \`Hello, \${name}!\`;
}
export class UserService {
private users: Map<string, User> = new Map();
getUser(id: string): User | undefined {
return this.users.get(id);
}
addUser(user: User): void {
this.users.set(user.id, user);
}
}
export interface User {
id: string;
name: string;
email?: string;
}
export const MAX_USERS = 1000;
type UserMap = Map<string, User>;
export default function main() {
const svc = new UserService();
svc.addUser({ id: "1", name: "Alice" });
}
`;
const tsTree = await mgr.parse("/tmp/test.ts", tsCode);
assert(tsTree !== null, "parsed TypeScript successfully");
const tsSymbols = extractSymbols(tsTree!, "typescript");
const names = tsSymbols.map(s => s.name);
console.log(" Symbols found:", names);
assert(names.includes("greet"), "found function greet");
assert(names.includes("UserService"), "found class UserService");
assert(names.includes("User"), "found interface User");
assert(names.includes("MAX_USERS"), "found const MAX_USERS");
assert(names.includes("main"), "found default export function main");
assert(names.includes("UserMap"), "found type alias UserMap");
// Check nested methods
const userServiceSym = tsSymbols.find(s => s.name === "UserService");
assert(userServiceSym?.children !== undefined, "UserService has children");
const methodNames = userServiceSym?.children?.map(c => c.name) ?? [];
assert(methodNames.includes("getUser"), "UserService.getUser found");
assert(methodNames.includes("addUser"), "UserService.addUser found");
// ── 3. Parse Python ──
console.log("\n🐍 Parse Python");
const pyCode = `
import os
class Calculator:
def __init__(self):
self.history = []
def add(self, a, b):
result = a + b
self.history.append(result)
return result
def multiply(self, a, b):
return a * b
def main():
calc = Calculator()
print(calc.add(2, 3))
MAX_HISTORY = 100
`;
const pyTree = await mgr.parse("/tmp/test.py", pyCode);
assert(pyTree !== null, "parsed Python successfully");
const pySymbols = extractSymbols(pyTree!, "python");
const pyNames = pySymbols.map(s => s.name);
console.log(" Symbols found:", pyNames);
assert(pyNames.includes("Calculator"), "found class Calculator");
assert(pyNames.includes("main"), "found function main");
const calcSym = pySymbols.find(s => s.name === "Calculator");
const calcMethods = calcSym?.children?.map(c => c.name) ?? [];
assert(calcMethods.includes("__init__"), "Calculator.__init__ found");
assert(calcMethods.includes("add"), "Calculator.add found");
// ── 4. Parse Rust ──
console.log("\n🦀 Parse Rust");
const rsCode = `
pub fn process(data: &[u8]) -> Result<Vec<u8>, Error> {
Ok(data.to_vec())
}
pub struct Config {
pub name: String,
pub value: i32,
}
impl Config {
pub fn new(name: &str) -> Self {
Config { name: name.to_string(), value: 0 }
}
}
pub trait Processor {
fn process(&self, input: &str) -> String;
}
pub enum Status {
Active,
Inactive,
Error(String),
}
const MAX_SIZE: usize = 1024;
`;
const rsTree = await mgr.parse("/tmp/test.rs", rsCode);
assert(rsTree !== null, "parsed Rust successfully");
const rsSymbols = extractSymbols(rsTree!, "rust");
const rsNames = rsSymbols.map(s => s.name);
console.log(" Symbols found:", rsNames);
assert(rsNames.includes("process"), "found fn process");
assert(rsNames.includes("Config"), "found struct Config");
assert(rsNames.some(n => n.startsWith("impl")), "found impl block");
assert(rsNames.includes("Processor"), "found trait Processor");
assert(rsNames.includes("Status"), "found enum Status");
assert(rsNames.includes("MAX_SIZE"), "found const MAX_SIZE");
// ── 5. Parse Go ──
console.log("\n🔵 Parse Go");
const goCode = `
package main
import "fmt"
func Add(a, b int) int {
return a + b
}
type Server struct {
Port int
Host string
}
func (s *Server) Start() error {
return nil
}
const MaxRetries = 3
var DefaultServer = Server{Port: 8080}
`;
const goTree = await mgr.parse("/tmp/test.go", goCode);
assert(goTree !== null, "parsed Go successfully");
const goSymbols = extractSymbols(goTree!, "go");
const goNames = goSymbols.map(s => s.name);
console.log(" Symbols found:", goNames);
assert(goNames.includes("Add"), "found func Add");
assert(goNames.includes("Server"), "found type Server");
assert(goNames.includes("Start"), "found method Start");
assert(goNames.includes("MaxRetries"), "found const MaxRetries");
// ── 6. Parse Java ──
console.log("\n☕ Parse Java");
const javaCode = `
package com.example;
public class Handler {
private final String name;
public Handler(String name) {
this.name = name;
}
public String handle(String input) {
return name + ": " + input;
}
}
interface Processor {
void process(String data);
}
enum Status {
OK, ERROR, PENDING
}
`;
const javaTree = await mgr.parse("/tmp/Test.java", javaCode);
assert(javaTree !== null, "parsed Java successfully");
const javaSymbols = extractSymbols(javaTree!, "java");
const javaNames = javaSymbols.map(s => s.name);
console.log(" Symbols found:", javaNames);
assert(javaNames.includes("Handler"), "found class Handler");
assert(javaNames.includes("Processor"), "found interface Processor");
assert(javaNames.includes("Status"), "found enum Status");
const handlerSym = javaSymbols.find(s => s.name === "Handler");
const handlerMethods = handlerSym?.children?.map(c => c.name) ?? [];
assert(handlerMethods.includes("handle"), "Handler.handle found");
// ── 7. Node at position & definition finding ──
console.log("\n📍 Node at position & definition lookup");
// "greet" starts at line 2 col 17 in tsCode (0-indexed: row=1, col=16)
const node = getNodeAtPosition(tsTree!, 1, 17);
assert(node !== null, "found node at position");
assert(node?.text === "greet", `node text is "greet" (got "${node?.text}")`);
const defs = findDefinition(tsTree!, "greet", "typescript");
assert(defs.length > 0, "found definition of greet");
assert(defs[0].name === "greet", "definition name matches");
// ── 8. Syntax errors ──
console.log("\n🔴 Syntax error detection");
const badCode = `function foo( { return 42; }`;
const badTree = await mgr.parse("/tmp/bad.ts", badCode);
assert(badTree !== null, "parsed bad code (with errors)");
const errors = getSyntaxErrors(badTree!);
assert(errors.length > 0, `found ${errors.length} syntax error(s)`);
console.log(" Errors:", errors.map(e => e.message));
// ── 9. Enclosing declaration ──
console.log("\n🏗 Enclosing declaration");
// Line 10 (0-indexed: 9) is inside UserService.getUser
const enclosing = getEnclosingDeclaration(tsTree!, 9, 10);
assert(enclosing !== null, "found enclosing declaration");
if (enclosing) {
const sig = getSignatureText(enclosing);
console.log(` Signature: ${sig}`);
assert(sig.includes("getUser"), `enclosing is getUser (got: ${sig})`);
}
// ── 10. Workspace index ──
console.log("\n📚 Workspace index");
const testDir = resolve("/tmp/tree-sitter-test-workspace");
try { rmSync(testDir, { recursive: true }); } catch {}
mkdirSync(resolve(testDir, "src"), { recursive: true });
writeFileSync(resolve(testDir, "src/app.ts"), `
export class AppService {
start(): void {}
stop(): void {}
}
export function createApp(): AppService {
return new AppService();
}
`);
writeFileSync(resolve(testDir, "src/utils.ts"), `
export function formatDate(d: Date): string {
return d.toISOString();
}
export function parseDate(s: string): Date {
return new Date(s);
}
export const VERSION = "1.0.0";
`);
writeFileSync(resolve(testDir, "src/main.py"), `
class Database:
def connect(self):
pass
def run_server(port):
db = Database()
db.connect()
`);
const wsIndex = new WorkspaceIndex(testDir, mgr);
await wsIndex.build();
const stats = wsIndex.getStats();
console.log(` Indexed ${stats.files} files, ${stats.symbols} symbols`);
assert(stats.files === 3, `indexed 3 files (got ${stats.files})`);
assert(stats.symbols > 0, `has symbols (got ${stats.symbols})`);
// Search
const appResults = wsIndex.search("AppService");
assert(appResults.length > 0, "found AppService in index");
assert(appResults[0].name === "AppService", "first result is AppService");
const formatResults = wsIndex.search("format");
assert(formatResults.length > 0, "found format* in index");
assert(formatResults.some(r => r.name === "formatDate"), "found formatDate");
// Cross-language search
const dbResults = wsIndex.search("Database");
assert(dbResults.length > 0, "found Python class Database in index");
// File-specific symbols
const appSyms = wsIndex.getSymbolsForFile(resolve(testDir, "src/app.ts"));
assert(appSyms.length > 0, `app.ts has symbols (got ${appSyms.length})`);
// Re-index after change
writeFileSync(resolve(testDir, "src/utils.ts"), `
export function formatDate(d: Date): string {
return d.toISOString();
}
export function newHelper(): void {}
`);
await wsIndex.indexFile(resolve(testDir, "src/utils.ts"));
const newResults = wsIndex.search("newHelper");
assert(newResults.length > 0, "found newHelper after re-index");
const versionResults = wsIndex.search("VERSION");
assert(versionResults.length === 0, "VERSION removed after re-index");
// ── 11. Tree caching ──
console.log("\n💾 Tree caching");
const tree1 = await mgr.parse("/tmp/cache-test.ts", "function a() {}");
const tree2 = await mgr.parse("/tmp/cache-test.ts", "function a() {}");
assert(tree1 === tree2, "same content returns cached tree");
const tree3 = await mgr.parse("/tmp/cache-test.ts", "function b() {}");
assert(tree3 !== tree1, "different content returns new tree");
mgr.invalidate("/tmp/cache-test.ts");
assert(mgr.getCachedTree("/tmp/cache-test.ts") === null, "invalidate clears cache");
// ── 12. Windows path regression (fileURLToPath vs .pathname) ──
// On Windows, import.meta.url is 'file:///C:/path/...'.
// Using .pathname gives '/C:/path/...' — path.resolve() treats the leading
// slash as 'root of current drive', so if CWD is on E:\ you get E:\C:\...
// Using fileURLToPath() correctly produces 'C:\path\...' on Windows.
console.log("\n🪟 Windows path regression (fileURLToPath vs .pathname)");
const moduleDir = fileURLToPath(new URL(".", import.meta.url));
const wasmPath = resolve(moduleDir, "node_modules/web-tree-sitter/tree-sitter.wasm");
assert(existsSync(wasmPath), `wasm resolves to a real file: ${wasmPath}`);
// Detect drive-doubling: a sign that .pathname was used on Windows.
// e.g. 'E:\\C:\\Users\\...' when CWD drive != module drive.
const hasDriveDoubling = /[A-Za-z]:\\[A-Za-z]:\\/.test(wasmPath);
assert(!hasDriveDoubling, `path has no doubled drive letter: ${wasmPath}`);
// Also verify the grammar dir resolves cleanly to real .wasm grammar files.
const grammarPath = resolve(moduleDir, "node_modules/tree-sitter-wasms/out/tree-sitter-typescript.wasm");
assert(existsSync(grammarPath), `grammar wasm resolves to a real file: ${grammarPath}`);
// ── Cleanup ──
try { rmSync(testDir, { recursive: true }); } catch {}
mgr.shutdown();
// ── Summary ──
console.log(`\n${"═".repeat(50)}`);
console.log(` ${passed} passed, ${failed} failed`);
console.log(`${"═".repeat(50)}\n`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,326 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [0.5.0] - 2026-07-03
### Added
- Added an `enabled` config toggle that gates tool override registration, with reload cleanup that disposes overrides and patches on `session_shutdown`. ([c78163d](https://github.com/MasuRii/pi-tool-display/commit/c78163dddc0f94b7a542d2d1e01109c903bc70cc))
- Added regression coverage for the active backlog: expanded large-diff rendering in constrained tmux-style panes (#23) and the esbuild lockfile bump from PR #24. ([7e46231](https://github.com/MasuRii/pi-tool-display/commit/7e4623191583f31a056602d8a08f1a4a7accd8b6))
### Changed
- Widened Pi coding-agent and Pi TUI peer dependency ranges through `^0.80.0` and added a `postinstall` patch with npm `overrides` to resolve known vulnerabilities in transitive dependencies. ([5f753a9](https://github.com/MasuRii/pi-tool-display/commit/5f753a9407fca6c2a7463d90eede01ce056c7bbc))
- Extracted render helpers and consolidated tool override logic to reduce inline duplication. ([3239d7a](https://github.com/MasuRii/pi-tool-display/commit/3239d7a825fe14e5cdcac86c75dd4b21aec0018c))
- Updated the lockfile-resolved `esbuild` dependency from `0.28.0` to `0.28.1` via Dependabot PR #24. ([caf65f2](https://github.com/MasuRii/pi-tool-display/commit/caf65f209c49ddcfc8362ff95c58a6a91cd1ba03))
- Updated README badge styling and added a ko-fi support button. ([2094fb6](https://github.com/MasuRii/pi-tool-display/commit/2094fb64ca4e79ff7d947d0f8be2f2d9ea967fe2))
### Fixed
- Capped expanded edit/write diff bodies with the existing `expandedPreviewMaxLines` setting and a visible omission hint so large diffs stay bounded in small tmux panes (#23). Thanks to @jmikedupont2 for reporting. ([7e46231](https://github.com/MasuRii/pi-tool-display/commit/7e4623191583f31a056602d8a08f1a4a7accd8b6))
## [0.4.3] - 2026-06-16
### Added
- Added `customToolOverrides` for explicit opt-in rendering of non-built-in extension tools, with `generic` as the default kind and optional `mcp` rendering for MCP proxy-style arguments.
- Added custom tool override coverage for malformed config, output modes, late tool registration, argument shapes, and runtime contract preservation.
### Changed
- Preserved configured MCP output mode even when MCP tools are not detected at startup, so dynamically registered MCP tools can still be decorated later.
### Fixed
- Bash tool display overrides now preserve Pi `settings.json` shell settings (`shellPath` and `shellCommandPrefix`) when rebuilding the bash tool.
## [0.4.2] - 2026-06-01
### Changed
- Deferred config modal, settings inspector, and built-in tool metadata loading until needed to reduce startup work.
- Replaced shared agent-directory lookup with a local `PI_CODING_AGENT_DIR`-aware resolver for config and capability checks.
- Widened peer dependency ranges to `^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0`.
### Fixed
- Corrected classic-mode diff line-number gutter spacing.
## [0.4.1] - 2026-05-26
### Added
- Reload-safe extension lifecycle: `src/disposable.ts` cleanup registry that disposes all tool overrides, prototype patches, timers, and event handlers on `session_shutdown(reason: "reload")`, preventing orphaned pi-mono default rendering after `/reload`.
- Comprehensive test suite with 15 new test files covering reload behavior, bash display, MCP overrides, ANSI utilities, diff renderer edge cases, user message boxes, thinking labels, render utilities, and integration tests (696 total tests, up from 68).
### Fixed
- Bash display now respects `shellPath` and `commandPrefix` from `settings.json` when present (#21).
- Bash spinner timer reworked to use toolCallId-keyed Map instead of `__piToolDisplayBashSpinner`, with interval reduced from 80ms to 200ms, defensive `invalidate()` check, and all timers registered in the cleanup registry (#19).
- Bash override registration is now deferred (like read/grep/edit) and uses `before_agent_start` to discover ownership via `pi.getAllTools()` before overriding, preventing conflicts with other extensions (#17). Thanks to @iwinux for reporting.
- `pi.registerTool` is now intercepted to decorate MCP tools as they register, eliminating a race condition where `session_start`/`before_agent_start` fire before `pi-mcp-adapter` finishes registering tools (#15, #18). Thanks to @dashanlkk for reporting and opening PR #18.
- `isMcpToolCandidate()` heuristics expanded to match `mcp`, `mcp_*`, `*_mcp`, names containing `server:` or starting with `ctx_`, and parameter schemas containing `mcpServer`, `serverUrl`, or `server_name`, catching many MCP servers that were previously false negatives.
- `stripBackgroundSgrParams()` now correctly preserves foreground RGB sequences like `38;2;12;49;200m` instead of misinterpreting color component `49` as a background reset (#8, #3). Thanks to @michaelrommel for the patch and @w-winter for reporting.
- `patchUserMessageRenderPrototype` now restores stale patches from prior extension instances before re-patching, and `registerNativeUserMessageBox` has a duplicate-prevention guard with `session_shutdown` restoration (#10). OSC 133 stripping is now scoped to prompt-control sequences only; OSC 8 hyperlinks are preserved. Thanks to @w-winter for reporting.
- Thinking label duplicate-prevention guard prevents re-registering event handlers across reloads; `session_shutdown(reason: "reload")` resets the guard so re-registration works after reload; recursive nested-array handling added for malformed thinking content (#2). Thanks to @agustif for PR #2.
- `registerDeferredBuiltInToolOverrides()` is now also called on `session_start` (not just `before_agent_start`), fixing a reload bug where read/grep/edit/bash tools fell back to default pi-mono rendering.
## [0.4.0] - 2026-05-22
### Added
- Added the `./tool-display-api-consumer` subpath export so other extensions can decorate tool definitions through the runtime tool-display API or queue decorations until `pi-tool-display` is loaded.
- Added hashline-anchor-aware diff rendering so read/edit anchor lines can display their `LINE#HASH` labels in the diff gutter.
### Changed
- Deferred built-in tool override registration until the built-in owner is available and refreshed cached built-in tools on session lifecycle changes.
- Redacted secret-like debug payload values and switched debug writes to asynchronous buffered file logging.
## [0.3.6] - 2026-05-04
### Added
- Documented the `debug` config flag for opt-in file diagnostics under the runtime-created `debug/` directory with terminal debug output kept disabled.
- Added regression coverage for config store isolation, pending diff preview safety, tool override registration, presets, and thinking label rendering.
### Changed
- Scoped projected pending edit and write previews to the active workspace before reading files, with clear fallback notices when previews cannot be resolved safely.
- Shared ANSI sanitization and width-safety helpers across diff and user message rendering for more consistent narrow-pane output.
### Fixed
- Hardened pending write metadata tracking so preview and execution state do not leak across tool call lifecycles.
- Improved tool override preview reads and write state handling for safer partial-render updates.
## [0.3.5] - 2026-04-27
### Changed
- Removed the deleted bundled screenshot asset from published package contents and removed the corresponding README project-structure reference to `assets/pi-tool-display.png`.
## [0.3.4] - 2026-04-24
### Added
- Added projected pending diff previews for partial `edit` and `write` tool calls so the TUI can show `pending edit`, `pending overwrite`, and `pending create` diffs before execution finishes
- Added preview fallback notices when projected edit previews cannot be resolved deterministically from the current file contents
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to `^0.70.2`
- Diff renderer write headers now support contextual action labels so pending previews can display `pending edit`, `pending overwrite`, and `pending create`
### Fixed
- Restored native user message box spacing on recent Pi releases by extracting markdown through the newer nested `Box` wrapper and stripping OSC 133 prompt markers from fallback content normalization
- Limited fallback OSC stripping to OSC 133 prompt markers so OSC 8 hyperlinks and other non-prompt OSC sequences remain intact in user message rendering
## [0.3.2] - 2026-04-15
### Added
- `diffIndicatorMode` config option with three styles: `bars` (persistent vertical indicators), `classic` (+/- markers on first row only), and `none` (no indicator column)
- Config modal dropdown for diff indicator style selection under "Diff indicators" setting
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to `^0.67.2`
- Config path resolution now uses `getAgentDir()` API to correctly respect `PI_CODING_AGENT_DIR` environment variable (thanks to @tynanbe for PR #6)
- Diff renderer now supports mode-aware indicator glyph resolution (bars, classic, none)
- Line prefix width calculations adjusted per indicator mode for accurate diff column alignment
- Removed unused `session_switch` listener from native user message box registration
- Added top margin line to native user message box rendering (thanks to @w-winter for the suggestion)
- Rebalanced diff row and inline emphasis background mixing for more consistent added/removed line readability
### Fixed
- Diff indicator markers now render correctly across all indicator modes with proper continuation handling
- Classic mode now shows +/- only on first visual row, with spacing on wrapped continuation lines
- Corrected ANSI background reset detection so RGB color sequences containing component value `49` no longer break inline diff emphasis background rendering (thanks to @michaelrommel for reporting issue #8)
## [0.3.1] - 2026-04-01
### Changed
- Updated npm keywords and package metadata for improved discoverability
- Added Related Pi Extensions cross-linking section to README
## [0.3.0] - 2026-04-01
### Added
- `prepareArguments` delegate support for built-in tool overrides (read, grep, find, ls, edit, write, bash)
- `buildPromptSnippetFromDescription()` helper to derive prompt snippets from MCP tool descriptions
- MCP proxy prompt metadata (`MCP_PROXY_PROMPT_SNIPPET`, `MCP_PROXY_PROMPT_GUIDELINES`) for tool registration
- Write execution metadata tracking via tool call ID for accurate diff rendering across execution lifecycle
- `applyLineBackgroundToWidth()` helper for consistent line background handling in diff renderer
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.64.0
- Refactored tool-overrides to use context-based argument extraction instead of closure state
- Improved diff renderer width handling with cleaner background reset logic
- Simplified continuation prefix rendering by removing unnecessary row background parameters
- Enhanced MCP proxy tool registration with proper prompt metadata propagation
### Fixed
- Write diff rendering now correctly tracks previous content and file existence state across render phases
- Tool call rendering now uses context-based state instead of global mutable state
### Tests
- Added tests for diff renderer width handling with line backgrounds
- Added tests for tool-overrides configuration and prepareArguments delegation
## [0.2.0] - 2026-03-24
### Added
- `bashOutputMode` config option with three modes: `opencode` (classic collapse), `summary` (line count only), `preview` (show lines)
- Live bash preview with spinner animation and elapsed time during command execution
- `bash-display.ts` module for bash call rendering with spinner state management
- `modal-icons.ts` module for Nerd Font detection and modal icon sets (`PI_NERD_FONTS` and `POWERLINE_NERD_FONTS` env vars)
- `settings-inspector-modal.ts` module with split-pane inspector UI (category list + setting details)
- Search icon in settings inspector modal filter hint (Nerd Font `\uF002` or emoji `🔍`)
### Changed
- Settings modal now uses split-pane inspector with setting descriptions, summaries, and advanced notes
- Modal width increased to accommodate split-pane layout (wider terminals get more space)
- `showTruncationHints` config now defaults to `false`
- `showRtkCompactionHints` config now defaults to `false`
- Bash output now supports different rendering modes controlled by `bashOutputMode`
- Refactored config modal to use new inspector modal component instead of legacy settings modal
### Config
- Added `bashOutputMode: "opencode" | "summary" | "preview"` to config schema
- Updated example config to demonstrate new `bashOutputMode` option
- Presets now include appropriate `bashOutputMode` values ("summary" for compact, "preview" for verbose)
### Tests
- Added comprehensive tests for bash output modes (opencode, summary, preview)
- Added test coverage for spinner state management and elapsed time formatting
- Added tests for modal icon detection with various terminal environments
## [0.1.12] - 2026-03-23
### Added
- `tool-metadata.ts` module with shared utilities: `toRecord`, `getTextField`, `isMcpToolCandidate`, `extractPromptMetadata`
- `cloneToolParameters` function to deep-copy built-in tool parameter schemas for extension renderers
- Comprehensive tests for MCP detection, config guards, output modes, and metadata cloning
- Plural label support in search summaries
- Conditional truncation hints via `showTruncationHints` config (defaults to `false`)
- Keywords for better npm discoverability: `hide`, `collapse`, `truncate`, `compact`, `diff`, `output-mode`
### Changed
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to ^0.62.0
- Extracted shared utilities to dedicated `tool-metadata.ts` module for reuse across capabilities and tool-overrides
- Refactored tool-overrides to preserve `promptSnippet` and `promptGuidelines` on overridden read, edit, and write tools
- Improved diff renderer with accurate line number tracking and line number delta calculation for proper hunk tracking
- Removed Windows path normalization from system prompt sanitizer working-directory handling
- Simplified system prompt sanitizer to only handle documentation removal
- Enhanced package description to highlight hide/collapse/truncate capabilities for better npm discovery
### Tests
- Added test suites for diff renderer numbering and wrap handling
- Added tests for tool-overrides config and registration behavior
- Added tests for capabilities module with MCP detection scenarios
## [0.1.11] - 2026-03-13
### Changed
- Refactored `sequenceAffectsBackground` to `sequenceResetsBackground` with simpler logic that only detects background reset sequences (codes 0 and 49)
### Tests
- Added test coverage for diff-renderer width handling
## [0.1.10] - 2026-03-13
### Fixed
- Add npm override for file-type >=21.3.1 to resolve CVE (infinite loop in ASF parser)
## [0.1.9] - 2026-03-13
### Added
- Write overwrite diff guard to skip expensive diff computation for large files (4000+ lines or 1M+ cells)
- Cached user message markdown renderer to avoid rebuilding markdown for large content on every render
- Configurable limits for user message markdown (100K characters, 2000 lines)
### Changed
- Improved performance for large write operations by using approximate stats instead of computing full diff
- User message markdown now bypasses rebuild for extremely large content (>100K chars or >2000 lines)
- Optimized write diff rendering to defer detailed data computation until actually needed
### Fixed
- Prevented UI slowdown on very large file writes by showing guard message instead of computing expensive diffs
- Avoided redundant markdown parser instantiation for repeated renders of the same user message
## [0.1.8] - 2026-03-12
### Changed
- Extracted diff presentation logic into dedicated `diff-presentation.ts` module with `DiffPresentationMode`, `buildDiffSummaryText`, `normalizeDiffRenderWidth`, and `resolveDiffPresentationMode` utilities
- Improved compact line rendering with dedicated marker and prefix functions
- Added width-safe diff rendering utilities for consistent terminal width handling
## [0.1.7] - 2026-03-07
### Added
- Added line-width safety utilities for diff rendering so collapsed and expanded diff output can be clamped to the current pane width.
- Added utility test coverage for write display helpers, native user message box helpers, and narrow-width diff hint behavior.
### Changed
- Updated README documentation to reflect the current command surface, config model, width-safe diff behavior, native user message box pipeline, and project structure.
- Refactored native user message box rendering into focused markdown, patching, renderer, and ANSI/background utility modules.
### Fixed
- Prevented diff rendering and collapsed diff hints from overflowing narrow terminal widths by progressively shortening hint text and clamping rendered lines.
- Preserved inline `write` call summaries with line-count and byte-size metadata when content is available.
- Prevented thinking label presentation changes from leaking into future assistant context by sanitizing stored thinking blocks during the `context` extension event.
- Hardened thinking label normalization to strip ANSI residue fragments such as `38;5;208m` before display formatting.
- Restored final-message thinking label persistence on `message_end` so themed labels remain consistent after streaming and across session reloads.
- Improved native user message box rendering so markdown content, ANSI-only blank lines, and background fill behave more consistently.
## [0.1.6] - 2026-03-04
### Fixed
- Use absolute GitHub raw URL for README image to fix npm display
## [0.1.5] - 2026-03-04
### Added
- Thinking labels feature that prefixes AI reasoning blocks with themed "Thinking:" labels for better readability
### Changed
- Rewrote README.md with professional documentation standards
- Added comprehensive feature documentation, configuration reference, and usage examples
- Simplified settings modal by removing less-used advanced options (expandedPreviewMaxLines, diffSplitMinWidth, diffCollapsedLines, tool ownership toggles)
## [0.1.4] - 2026-03-02
### Added
- Auto-detection of MCP and RTK capabilities to conditionally expose related UI/config controls.
### Changed
- `/tool-display` modal now hides MCP settings when MCP tooling is unavailable.
- `/tool-display` modal now hides RTK compaction hint settings when RTK optimizer is unavailable.
- Runtime rendering now force-disables MCP output mode and RTK hint rendering when those capabilities are unavailable.
- Native user message box is now user-configurable via `enableNativeUserMessageBox` in config and `/tool-display` settings.
## [0.1.3] - 2026-03-02
### Added
- Added per-tool ownership config via `registerToolOverrides` for `read`, `grep`, `find`, `ls`, `bash`, `edit`, and `write` so users can avoid tool ownership conflicts with other extensions.
- Added settings modal toggles for built-in tool ownership and `/reload` guidance when ownership changes.
- Added backward-compatible config migration from legacy `registerReadToolOverride` to `registerToolOverrides.read`.
### Changed
- Built-in tool override registration is now conditional per tool based on ownership settings.
- Updated README configuration/troubleshooting docs for multi-tool extension compatibility.
## [0.1.2] - 2026-03-01
### Fixed
- Corrected `write` call rendering state handling so path changes without new content no longer reuse stale line/size metadata from previous writes.
- Restored write call suffix rendering (`(X lines, Y)`) when content is available, improving call summary consistency.
## [0.1.1] - 2026-03-01
### Changed
- Reorganized repository layout to a cleaner package structure:
- moved implementation modules to `src/`
- moved screenshot assets to `assets/`
- moved example config to `config/`
- kept root `index.ts` as stable Pi auto-discovery entrypoint.
- Simplified TypeScript build command to use `tsconfig.json` project mode.
- Updated README installation heading now that npm package is published.
## [0.1.0] - 2026-03-01
### Added
- Public repository scaffolding (`README.md`, `LICENSE`, `CHANGELOG.md`, `.gitignore`, `.npmignore`).
- Package metadata for public distribution (`keywords`, `files`, `license`, `publishConfig`, engine constraints).
- Vendored `zellij-modal.ts` to keep this extension self-contained as a standalone repository.
### Changed
- Updated `config-modal.ts` to use local `zellij-modal.ts` import.
- Updated build script to include `zellij-modal.ts`.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MasuRii
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,413 @@
<div align="center">
# pi-tool-display
[![npm version](https://img.shields.io/npm/v/pi-tool-display?style=for-the-badge)](https://www.npmjs.com/package/pi-tool-display)
[![License](https://img.shields.io/github/license/MasuRii/pi-tool-display?style=for-the-badge)](LICENSE)
[![Platform](https://img.shields.io/badge/Platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=for-the-badge)]()
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/Y8Y01PSSVR)
OpenCode-style tool rendering for the [Pi coding agent](https://github.com/mariozechner/pi).
`pi-tool-display` keeps tool calls compact by default, adds richer diff rendering for file edits, and improves a few core chat UI details such as thinking labels and the native user prompt box.
<img width="1360" height="752" alt="image" src="https://github.com/user-attachments/assets/777944a2-18b2-4642-b035-2c703a5abb1b" />
<img width="978" height="670" alt="image" src="https://github.com/user-attachments/assets/122b69ce-6c99-4aaa-ba93-236f97a1d8b4" />
<img width="1920" height="1080" alt="image" src="https://github.com/user-attachments/assets/7d5e36d3-cbe1-4d54-8bed-ae3dbdef870c" />
<img width="1919" height="566" alt="image" src="https://github.com/user-attachments/assets/68a1619b-62da-480f-8de3-2af441ccf6ff" />
<img width="1919" height="550" alt="image" src="https://github.com/user-attachments/assets/1d3f0b38-a5b5-47fc-b54b-8b55cc2bfaf1" />
</div>
## Features
- **Compact built-in tool rendering** for `read`, `grep`, `find`, `ls`, `bash`, `edit`, and `write`
- **MCP-aware rendering** with hidden, summary, and preview modes
- **Opt-in custom tool overrides** for noisy extension tools, defaulting to generic rendering unless `kind: "mcp"` is selected
- **Adaptive edit/write diffs** with split or unified layouts, syntax highlighting, inline emphasis, and narrow-pane width clamping
- **Workspace-scoped projected pending edit/write previews** that show `pending edit`, `pending overwrite`, and `pending create` diffs while partial tool calls are still streaming
- **Progressive collapsed diff hints** that shorten automatically on small terminal widths instead of overflowing
- **Hashline-anchor diff gutters** that preserve `LINE#HASH` labels from anchored read/edit output when those lines are rendered in diffs
- **Three presets**: `opencode`, `balanced`, and `verbose`
- **Thinking labels** during streaming and final message rendering, with context sanitization to avoid leaking presentation labels back into future model turns
- **Optional native user message box** with markdown-aware rendering and safer ANSI/background handling
- **Per-tool ownership toggles** so this extension can coexist with other renderer extensions
- **Capability-aware settings** that keep MCP and RTK-specific controls aligned with the current environment
- **Adapter API for renderer consumers** through the `pi-tool-display/tool-display-api-consumer` subpath export
## Installation
### Local extension folder
Place this folder in one of Pi's auto-discovery locations:
```text
# Global default (when PI_CODING_AGENT_DIR is unset)
~/.pi/agent/extensions/pi-tool-display
# Project-specific
.pi/extensions/pi-tool-display
```
### npm package
```bash
pi install npm:pi-tool-display
```
### Git repository
```bash
pi install git:github.com/MasuRii/pi-tool-display
```
## Usage
### Interactive settings
Open the settings modal:
```text
/tool-display
```
The modal exposes the day-to-day controls most people change regularly:
- preset profile
- read output mode
- grep/find/ls output mode
- MCP output mode (when MCP is available)
- preview line count
- bash collapsed line count
- diff layout mode
- native user message box toggle
Advanced options remain in `config.json`.
### Direct commands
```text
/tool-display show # Show the effective config summary
/tool-display reset # Reset to the default opencode preset
/tool-display preset opencode # Apply opencode preset
/tool-display preset balanced # Apply balanced preset
/tool-display preset verbose # Apply verbose preset
```
### Tool display adapter API
Other extensions can opt into `pi-tool-display` rendering without directly depending on its load order by importing the consumer helper:
```ts
import { decorateToolForDisplay, decorateMcpToolForDisplay } from "pi-tool-display/tool-display-api-consumer";
```
`decorateToolForDisplay(tool, adapter)` applies the runtime decoration immediately when `pi-tool-display` is loaded, or queues the decoration until the API becomes available. Use adapter options such as `kind: "read" | "edit" | "mcp" | "generic"` to select the renderer family; `decorateMcpToolForDisplay(tool)` is the shortcut for MCP-style tools.
## Presets
| Preset | Read Output | Search Output | MCP Output | Bash Output | Preview Lines | Bash Lines |
|--------|-------------|---------------|------------|--------------|---------------|------------|
| `opencode` | hidden | hidden | hidden | opencode | 8 | 10 |
| `balanced` | summary | count | summary | summary | 8 | 10 |
| `verbose` | preview | preview | preview | preview | 12 | 20 |
- **`opencode`** (default): minimal inline-only display; tool results stay collapsed
- **`balanced`**: compact summaries with line counts and match totals; bash shows line count only
- **`verbose`**: larger previews for read/search/MCP/bash output
### Bash Output Modes
| Mode | Behavior |
|------|----------|
| `opencode` | Classic collapsed output using `bashCollapsedLines` limit with expansion hint |
| `summary` | Shows only line count (e.g., "↳ 3 lines returned") — no output displayed |
| `preview` | Shows actual output lines using `previewLines` limit |
## Configuration
Runtime configuration is stored at:
```text
Default global path: ~/.pi/agent/extensions/pi-tool-display/config.json
Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-tool-display/config.json when PI_CODING_AGENT_DIR is set
```
A starter template is included at `config/config.example.json`.
### Configuration options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `debug` | boolean | `false` | Opt-in file logging for extension diagnostics; missing values are treated as `false` |
| `registerToolOverrides` | object | all `true` | Built-in tool ownership flags |
| `customToolOverrides` | object | `{}` | Explicit opt-in rendering rules for non-built-in extension tools |
| `enableNativeUserMessageBox` | boolean | `true` | Enable bordered user prompt rendering |
| `readOutputMode` | string | `"hidden"` | `hidden`, `summary`, or `preview` |
| `searchOutputMode` | string | `"hidden"` | `hidden`, `count`, or `preview` |
| `mcpOutputMode` | string | `"hidden"` | `hidden`, `summary`, or `preview` |
| `previewLines` | number | `8` | Lines shown in collapsed preview mode |
| `expandedPreviewMaxLines` | number | `4000` | Max preview lines when fully expanded |
| `bashOutputMode` | string | `"opencode"` | `opencode` (collapse), `summary` (line count), or `preview` (show lines) |
| `bashCollapsedLines` | number | `10` | Lines shown for collapsed bash output (opencode mode) |
| `diffViewMode` | string | `"auto"` | `auto`, `split`, or `unified` |
| `diffIndicatorMode` | string | `"bars"` | `bars` (vertical indicators), `classic` (+/- markers), or `none` |
| `diffSplitMinWidth` | number | `120` | Minimum width before auto mode prefers split diffs |
| `diffCollapsedLines` | number | `24` | Diff lines shown before collapsing |
| `diffWordWrap` | boolean | `true` | Wrap long diff lines when needed |
| `showTruncationHints` | boolean | `false` | Show truncation indicators for compacted output |
| `showRtkCompactionHints` | boolean | `false` | Show RTK compaction hints when RTK metadata exists |
### Tool ownership
Use `registerToolOverrides` to control which built-in tools this extension owns:
```json
{
"registerToolOverrides": {
"read": true,
"grep": true,
"find": true,
"ls": true,
"bash": true,
"edit": true,
"write": true
}
}
```
Set any entry to `false` if another extension should handle that tool instead.
> Changes to tool ownership take effect after `/reload`.
### Custom tool overrides
Use `customToolOverrides` when another extension registers a noisy top-level tool and you want `pi-tool-display` to render that tool's call/result output. Custom overrides are explicit opt-in only: unlisted or disabled tools keep their original renderers.
```json
{
"customToolOverrides": {
"ide_find_symbol": {
"enabled": true,
"kind": "generic",
"outputMode": "summary"
},
"custom_mcp_gateway": {
"enabled": true,
"kind": "mcp",
"outputMode": "preview"
}
}
}
```
Each entry supports:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | boolean | `true` | Whether `pi-tool-display` should decorate this custom tool |
| `kind` | string | `"generic"` | `generic` for plain compact output, or `mcp` for MCP-style call labels and result handling |
| `outputMode` | string | `"summary"` | `hidden`, `summary`, or `preview` for this custom tool's result output |
Boolean shorthand is also accepted:
```json
{
"customToolOverrides": {
"ide_find_symbol": true,
"noisy_tool_to_leave_alone": false
}
}
```
Notes:
- Built-in tool names (`read`, `grep`, `find`, `ls`, `bash`, `edit`, `write`) are ignored here; use `registerToolOverrides` for those.
- `generic` call rendering shows the tool name and argument count, then compacts the result according to `outputMode`.
- `mcp` call rendering understands MCP proxy-style arguments such as `tool`, `server`, `search`, `describe`, and `connect`.
- Changes for already-registered tools take effect after `/reload`; tools registered later can be decorated as they register.
### Example config
```json
{
"debug": false,
"registerToolOverrides": {
"read": true,
"grep": true,
"find": true,
"ls": true,
"bash": true,
"edit": true,
"write": true
},
"customToolOverrides": {
"ide_find_symbol": {
"enabled": true,
"kind": "generic",
"outputMode": "summary"
},
"custom_mcp_gateway": {
"enabled": true,
"kind": "mcp",
"outputMode": "preview"
}
},
"enableNativeUserMessageBox": true,
"readOutputMode": "summary",
"searchOutputMode": "count",
"mcpOutputMode": "summary",
"previewLines": 12,
"expandedPreviewMaxLines": 4000,
"bashOutputMode": "opencode",
"bashCollapsedLines": 15,
"diffViewMode": "auto",
"diffIndicatorMode": "bars",
"diffSplitMinWidth": 120,
"diffCollapsedLines": 24,
"diffWordWrap": true,
"showTruncationHints": false,
"showRtkCompactionHints": false
}
```
### Debug logging
Debug logging is disabled by default. Set `debug` to `true` in the extension root `config.json` only when collecting diagnostics; missing or non-`true` values are treated as `false`. When enabled, diagnostics are appended to `debug/debug.log` under a runtime-created `debug/` directory, and no debug output is written to the terminal.
## Rendering notes
### Edit and write diffs
`edit` and `write` results use the same diff renderer. In `auto` mode the extension chooses split or unified layout based on available width. On narrow panes it clamps rendered lines and shortens collapsed hint text so the diff stays readable instead of spilling past the terminal width.
While tool arguments are still streaming, partial `edit` and `write` calls can show projected pending previews. Deterministic edits render as `pending edit` diffs against current file contents, writes render as `pending overwrite` or `pending create`, and unresolved projections show a clear preview notice instead of guessing. Preview file reads are scoped to the active workspace so pending previews avoid reading paths outside the current project.
When diff input includes Pi anchored read lines such as `12#AB:content`, the renderer treats the anchor as line metadata and displays the `LINE#HASH` label in the gutter while keeping the content aligned for split, unified, and compact diff layouts.
### Write summaries
When content is available, `write` call summaries include line count and byte size information inline so you can quickly see the size of the pending write before expanding the result.
### Thinking labels
Thinking blocks are labeled during streaming and on final messages. Before the next model turn, the extension sanitizes those presentation labels out of the stored assistant context so they do not accumulate or pollute future prompts.
### Native user message box
When enabled, user prompts render inside a bordered box using Pi's native user message component. The renderer preserves markdown content more safely and normalizes ANSI/background handling to avoid odd nested background artifacts.
## Capability detection
The extension checks the current Pi environment and adjusts behavior automatically:
- **MCP tooling unavailable at startup**: MCP settings can be hidden from the modal, but the configured MCP output mode is preserved because MCP tools may register later
- **RTK optimizer unavailable**: RTK hint settings are hidden and RTK compaction hints are disabled
This keeps the UI aligned with the current environment while still allowing dynamically registered MCP tools to be styled when they appear.
## Troubleshooting
### Reload safety
`/reload` is fully supported. On reload, the extension cleans up all tool overrides, prototype patches, timers, and event handlers through its built-in disposal registry, then re-registers everything on the next session lifecycle event. No manual cleanup is needed.
### Tool ownership conflicts
If another extension is already rendering one of the built-in tools:
1. Set `registerToolOverrides.<tool>` to `false`
2. Run `/reload`
3. Use `/tool-display show` to confirm the effective ownership state
Built-in tool overrides (including `bash`) are registered with deferred ownership discovery — the extension discovers which tools it owns via `pi.getAllTools()` during `before_agent_start` before overriding, preventing conflicts with other extensions that also register overrides.
### Config not loading
If your settings are not being applied:
1. Check that the global Pi tool-display config exists (default: `~/.pi/agent/extensions/pi-tool-display/config.json`, respects `PI_CODING_AGENT_DIR`)
2. Make sure the JSON is valid
3. Run `/tool-display show` to inspect the effective config summary
### MCP or custom tool rendering not appearing
MCP tools are decorated via `pi.registerTool` interception, so they are captured as soon as they register regardless of lifecycle event ordering. If MCP tools still appear unstyled, check that the tool's name or parameter schema matches one of the supported MCP detection heuristics (names containing `mcp`, `server:`, `ctx_`, or parameter schemas with `mcpServer`/`serverUrl`/`server_name`).
For non-MCP extension tools, or MCP-like tools that do not match the heuristics, add the exact tool name under `customToolOverrides` and run `/reload`. Use `kind: "generic"` for ordinary tools and `kind: "mcp"` for MCP proxy-style arguments.
### MCP or RTK settings missing
Those controls only appear when the corresponding capability is available in the current Pi environment.
## Project structure
```text
pi-tool-display/
├── index.ts # Extension entrypoint for Pi auto-discovery
├── src/
│ ├── index.ts # Bootstrap and extension registration
│ ├── capabilities.ts # MCP/RTK capability detection
│ ├── config-modal.ts # /tool-display settings UI and command handling
│ ├── config-store.ts # Config load/save and normalization
│ ├── disposable.ts # Reload-safe cleanup registry for tool overrides, patches, and timers
│ ├── diff-renderer.ts # Edit/write diff rendering engine
│ ├── line-width-safety.ts # Width clamping helpers for narrow panes
│ ├── pending-diff-preview.ts # Partial edit/write preview projection helpers
│ ├── presets.ts # Preset definitions and matching
│ ├── render-utils.ts # Shared rendering helpers
│ ├── thinking-label.ts # Thinking label formatting and context sanitization
│ ├── tool-overrides.ts # Built-in, MCP, and opt-in custom renderer overrides
│ ├── types.ts # Shared config and type definitions
│ ├── user-message-box-markdown.ts # Markdown extraction for user message rendering
│ ├── user-message-box-native.ts # Native user message box registration
│ ├── user-message-box-patch.ts # Safe native render patching helpers
│ ├── user-message-box-renderer.ts # User message border renderer
│ ├── user-message-box-utils.ts # ANSI/background normalization helpers
│ ├── write-display-utils.ts # Write summary helpers
│ └── zellij-modal.ts # Modal UI primitives
├── config/
│ └── config.example.json # Starter config template
└── tests/
├── ansi-utils.test.ts # ANSI utility tests including foreground RGB preservation
├── bash-display.test.ts # Bash display and spinner tests
├── capabilities-edge.test.ts # Capability detection edge cases
├── config-modal.test.ts # Config modal tests
├── custom-tool-overrides.test.ts # Opt-in custom tool override tests
├── debug-logger-edge.test.ts # Debug logger edge cases
├── diff-renderer-ansi.test.ts # ANSI/background handling tests for diff rendering
├── diff-renderer-edge.test.ts # Diff renderer edge case tests
├── diff-renderer-width.test.ts # Width and background coverage tests for diff rendering
├── index-integration.test.ts # Integration tests for extension lifecycle
├── presets-edge.test.ts # Preset edge case tests
├── reload-behavior.test.ts # Reload-safe cleanup and re-registration tests
├── tool-overrides-config.test.ts # Tool override config tests
├── tool-overrides-registration.test.ts # Tool override registration tests
└── tool-ui-utils.test.ts # Utility tests for user message and diff helpers
```
## Development
```bash
# Type check
npm run build
# Run tests
npm run test
# Full verification
npm run check
```
## Related Pi Extensions
- [pi-image-tools](https://github.com/MasuRii/pi-image-tools) — Image attachment and inline preview for the Pi TUI
- [pi-hide-messages](https://github.com/MasuRii/pi-hide-messages) — Hide older chat messages without losing context
- [pi-startup-redraw-fix](https://github.com/MasuRii/pi-startup-redraw-fix) — Fix terminal redraw glitches on startup
- [pi-permission-system](https://github.com/MasuRii/pi-permission-system) — Permission enforcement for tool and command access
## License
[MIT](LICENSE)

View File

@@ -0,0 +1,39 @@
{
"debug": false,
"registerToolOverrides": {
"read": true,
"grep": true,
"find": true,
"ls": true,
"bash": true,
"edit": true,
"write": true
},
"customToolOverrides": {
"ide_find_symbol": {
"enabled": false,
"kind": "generic",
"outputMode": "summary"
},
"custom_mcp_gateway": {
"enabled": false,
"kind": "mcp",
"outputMode": "summary"
}
},
"enableNativeUserMessageBox": true,
"readOutputMode": "hidden",
"searchOutputMode": "hidden",
"mcpOutputMode": "hidden",
"previewLines": 8,
"expandedPreviewMaxLines": 4000,
"bashOutputMode": "opencode",
"bashCollapsedLines": 10,
"diffViewMode": "auto",
"diffIndicatorMode": "bars",
"diffSplitMinWidth": 120,
"diffCollapsedLines": 24,
"diffWordWrap": true,
"showTruncationHints": false,
"showRtkCompactionHints": false
}

View File

@@ -0,0 +1,3 @@
import toolDisplayExtension from "./src/index.js";
export default toolDisplayExtension;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
{
"name": "pi-tool-display",
"version": "0.5.0",
"description": "Compact tool call rendering, diff visualization, and output truncation extension for Pi coding agent. Hides, collapses, and truncates verbose tool output for cleaner TUI display.",
"type": "module",
"main": "./index.ts",
"exports": {
".": "./index.ts",
"./tool-display-api-consumer": {
"types": "./tool-display-api-consumer.d.ts",
"default": "./tool-display-api-consumer.js"
}
},
"files": [
"index.ts",
"tool-display-api-consumer.js",
"tool-display-api-consumer.d.ts",
"src",
"config/config.example.json",
"README.md",
"CHANGELOG.md",
"LICENSE"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"lint": "npm run typecheck",
"test": "tsx --test tests/*.test.ts",
"check": "npm run lint && npm run test",
"typecheck": "tsc -p tsconfig.json",
"postinstall": "node -e \"const fs=require('fs'),cp=require('child_process'),p=require('path');const cwd=process.cwd();const normalized=cwd.split(p.sep).join('/');if(!normalized.includes('/.pi/agent/extensions/'))process.exit(0);const s=p.resolve(cwd,'../../scripts/patch-vulnerable-deps.mjs');if(!fs.existsSync(s))process.exit(0);const r=cp.spawnSync(process.execPath,[s,'--target',cwd,'--quiet'],{stdio:'inherit'});process.exit(r.status||0)\""
},
"keywords": [
"pi-package",
"pi",
"pi-extension",
"pi-coding-agent",
"pi-tui",
"coding-agent",
"tui",
"tool-rendering",
"tool-output",
"diff-visualization",
"output-truncation",
"hide",
"collapse",
"truncate",
"compact",
"diff",
"output-mode"
],
"author": "MasuRii",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/MasuRii/pi-tool-display.git"
},
"bugs": {
"url": "https://github.com/MasuRii/pi-tool-display/issues"
},
"homepage": "https://github.com/MasuRii/pi-tool-display#readme",
"engines": {
"node": ">=20"
},
"publishConfig": {
"access": "public"
},
"pi": {
"extensions": [
"./index.ts"
]
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0",
"@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0"
},
"overrides": {
"file-type": ">=21.3.1",
"protobufjs": "7.6.3",
"ws": "8.21.0"
},
"devDependencies": {
"tsx": "4.22.4",
"typescript": "6.0.3",
"@earendil-works/pi-coding-agent": "^0.80.3"
}
}

View File

@@ -0,0 +1,32 @@
import { homedir } from "node:os";
import { join } from "node:path";
const PI_AGENT_DIR_ENV_VAR = "PI_CODING_AGENT_DIR";
interface AgentDirEnvironment {
[name: string]: string | undefined;
}
function expandHomeDirectory(configuredDir: string, homeDirectory: string): string {
if (configuredDir === "~") {
return homeDirectory;
}
if (configuredDir.startsWith("~/") || configuredDir.startsWith("~\\")) {
return join(homeDirectory, configuredDir.slice(2));
}
return configuredDir;
}
export function resolvePiAgentDir(
env: AgentDirEnvironment = process.env,
homeDirectory = homedir(),
): string {
const configuredDir = env[PI_AGENT_DIR_ENV_VAR];
if (!configuredDir) {
return join(homeDirectory, ".pi", "agent");
}
return expandHomeDirectory(configuredDir, homeDirectory);
}

View File

@@ -0,0 +1,116 @@
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
const STYLE_RESET_PARAMS = [39, 22, 23, 24, 25, 27, 28, 29, 59] as const;
export { ANSI_SGR_PATTERN, STYLE_RESET_PARAMS };
export function expandSgrReset(param: number): readonly number[] | undefined {
return param === 0 ? STYLE_RESET_PARAMS : undefined;
}
export function toSgrParams(rawParams: string): number[] {
if (!rawParams.trim()) {
return [0];
}
const parsed = rawParams
.split(";")
.map((token) => Number.parseInt(token, 10))
.filter((value) => Number.isFinite(value));
return parsed.length > 0 ? parsed : [];
}
export function isFiniteSgrParam(value: number | undefined): value is number {
return typeof value === "number" && Number.isFinite(value);
}
export function readSgrColorSequence(params: number[], index: number): number[] | undefined {
const param = params[index];
if (param !== 38 && param !== 48) {
return undefined;
}
const colorMode = params[index + 1];
if (colorMode === 5) {
const colorValue = params[index + 2];
return isFiniteSgrParam(colorValue) ? [param, colorMode, colorValue] : undefined;
}
if (colorMode === 2) {
const red = params[index + 2];
const green = params[index + 3];
const blue = params[index + 4];
return isFiniteSgrParam(red) && isFiniteSgrParam(green) && isFiniteSgrParam(blue)
? [param, colorMode, red, green, blue]
: undefined;
}
return undefined;
}
export function stripBackgroundSgrParams(params: readonly number[]): number[] {
const sanitized: number[] = [];
for (let index = 0; index < params.length; index++) {
const param = params[index] ?? 0;
if (param === 0) {
sanitized.push(...expandSgrReset(param)!);
continue;
}
if (param === 49 || (param >= 40 && param <= 47) || (param >= 100 && param <= 107)) {
continue;
}
if (param === 38 || param === 48) {
const sequence = readSgrColorSequence(params as number[], index);
if (sequence) {
if (param === 38) {
sanitized.push(...sequence);
}
index += sequence.length - 1;
continue;
}
const advance = params[index + 1] === 5 ? 2 : params[index + 1] === 2 ? 4 : 0;
if (advance > 0) {
index += advance;
if (param === 38) {
sanitized.push(param);
}
continue;
}
}
sanitized.push(param);
}
return sanitized;
}
export function filterSgrSequences(
text: string,
filter: (params: number[]) => number[],
): string {
if (!text || !text.includes("\x1b[")) {
return text;
}
return text.replace(ANSI_SGR_PATTERN, (_sequence, rawParams: string) => {
const parsed = toSgrParams(rawParams);
if (parsed.length === 0) {
return "";
}
const sanitized = filter(parsed);
if (sanitized.length === 0) {
return "";
}
return `\x1b[${sanitized.join(";")}m`;
});
}
export function sanitizeAnsiForThemedOutput(text: string): string {
return filterSgrSequences(text, stripBackgroundSgrParams);
}

View File

@@ -0,0 +1,212 @@
import { Text } from "@earendil-works/pi-tui";
import { registerCleanup, registerTimer } from "./disposable.js";
const BASH_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
const BASH_SPINNER_INTERVAL_MS = 200;
const BASH_SPINNER_STATE_KEY = "__piToolDisplayBashSpinner";
const BASH_SPINNER_TOOL_CALL_ID_KEY = "__piToolDisplayBashSpinnerToolCallId";
interface BashCallArgs {
command?: string;
commandPrefix?: string;
shellPath?: string;
timeout?: number;
}
interface BashCallRenderTheme {
fg(color: string, text: string): string;
bold(text: string): string;
}
interface BashSpinnerState {
frameIndex: number;
startedAt?: number;
timer?: ReturnType<typeof setInterval>;
}
interface BashSpinnerStateCarrier {
[BASH_SPINNER_STATE_KEY]?: BashSpinnerState;
[BASH_SPINNER_TOOL_CALL_ID_KEY]?: string;
}
interface BashCallRenderContextLike {
executionStarted: boolean;
isPartial: boolean;
invalidate?: () => void;
lastComponent?: unknown;
state?: unknown;
toolCallId?: string;
}
const spinnerStatesByToolCallId = new Map<string, BashSpinnerState>();
let nextSyntheticToolCallId = 0;
function toStateCarrier(value: unknown): BashSpinnerStateCarrier | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
return value as BashSpinnerStateCarrier;
}
function getSyntheticToolCallId(carrier: BashSpinnerStateCarrier | undefined): string | undefined {
if (!carrier) {
return undefined;
}
if (!carrier[BASH_SPINNER_TOOL_CALL_ID_KEY]) {
carrier[BASH_SPINNER_TOOL_CALL_ID_KEY] = `state:${++nextSyntheticToolCallId}`;
}
return carrier[BASH_SPINNER_TOOL_CALL_ID_KEY];
}
function getToolCallId(context: BashCallRenderContextLike): string | undefined {
if (typeof context.toolCallId === "string" && context.toolCallId.trim().length > 0) {
return context.toolCallId;
}
return getSyntheticToolCallId(toStateCarrier(context.state));
}
function getOrCreateSpinnerState(
toolCallId: string | undefined,
carrier: BashSpinnerStateCarrier | undefined,
): BashSpinnerState | undefined {
if (!toolCallId) {
return undefined;
}
let state = spinnerStatesByToolCallId.get(toolCallId);
if (!state) {
state = { frameIndex: 0 };
spinnerStatesByToolCallId.set(toolCallId, state);
}
if (carrier) {
carrier[BASH_SPINNER_STATE_KEY] = state;
}
return state;
}
function stopSpinner(toolCallId: string | undefined, state: BashSpinnerState | undefined): void {
if (!state) {
return;
}
if (state.timer) {
clearInterval(state.timer);
state.timer = undefined;
}
state.frameIndex = 0;
state.startedAt = undefined;
if (toolCallId) {
spinnerStatesByToolCallId.delete(toolCallId);
}
}
function formatElapsed(elapsedMs: number): string {
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const totalMinutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (totalMinutes < 60) {
return `${totalMinutes}m ${seconds}s`;
}
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}h ${minutes}m`;
}
function isDefaultShellPath(shellPath: string): boolean {
const normalized = shellPath.trim().replace(/\\/g, "/").toLowerCase();
const basename = normalized.split("/").pop() || normalized;
return basename === "bash" || basename === "cmd.exe";
}
function buildCommandDisplay(args: BashCallArgs): string {
const command =
typeof args.command === "string" && args.command.trim().length > 0
? args.command
: "...";
const prefix =
typeof args.commandPrefix === "string" && args.commandPrefix.trim().length > 0
? args.commandPrefix.trim()
: "";
return prefix ? `${prefix} ${command}` : command;
}
function buildBashCallText(
args: BashCallArgs,
theme: BashCallRenderTheme,
spinnerFrame?: string,
elapsedMs?: number,
): string {
const commandDisplay = buildCommandDisplay(args);
const shellSuffix =
typeof args.shellPath === "string" &&
args.shellPath.trim().length > 0 &&
!isDefaultShellPath(args.shellPath)
? theme.fg("muted", ` [shell: ${args.shellPath}]`)
: "";
const timeoutSuffix = args.timeout
? theme.fg("muted", ` (timeout ${args.timeout}s)`)
: "";
const spinnerPrefix = spinnerFrame ? `${theme.fg("warning", `${spinnerFrame} `)}` : "";
const elapsedSuffix =
spinnerFrame && elapsedMs !== undefined
? theme.fg("muted", ` · ${formatElapsed(elapsedMs)}`)
: "";
return `${spinnerPrefix}${theme.fg("toolTitle", theme.bold("$"))} ${theme.fg("accent", commandDisplay)}${shellSuffix}${timeoutSuffix}${elapsedSuffix}`;
}
export function renderBashCall(
args: BashCallArgs,
theme: BashCallRenderTheme,
context: BashCallRenderContextLike,
): Text {
const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
const carrier = toStateCarrier(context.state);
const toolCallId = getToolCallId(context);
const spinnerState = getOrCreateSpinnerState(toolCallId, carrier);
const shouldSpin = context.executionStarted && context.isPartial;
if (!shouldSpin) {
stopSpinner(toolCallId, spinnerState);
text.setText(buildBashCallText(args, theme));
return text;
}
if (spinnerState) {
spinnerState.startedAt ??= Date.now();
if (!spinnerState.timer && typeof context.invalidate === "function") {
const timer = setInterval(() => {
spinnerState.frameIndex = (spinnerState.frameIndex + 1) % BASH_SPINNER_FRAMES.length;
text.setText(
buildBashCallText(
args,
theme,
BASH_SPINNER_FRAMES[spinnerState.frameIndex],
Date.now() - (spinnerState.startedAt ?? Date.now()),
),
);
context.invalidate?.();
}, BASH_SPINNER_INTERVAL_MS);
spinnerState.timer = timer;
registerTimer(timer);
registerCleanup(() => {
if (spinnerStatesByToolCallId.get(toolCallId || "") === spinnerState) {
stopSpinner(toolCallId, spinnerState);
}
});
}
}
const spinnerFrame = spinnerState ? BASH_SPINNER_FRAMES[spinnerState.frameIndex] : undefined;
const elapsedMs = spinnerState?.startedAt !== undefined
? Date.now() - spinnerState.startedAt
: undefined;
text.setText(buildBashCallText(args, theme, spinnerFrame, elapsedMs));
return text;
}

View File

@@ -0,0 +1,85 @@
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { resolvePiAgentDir } from "./agent-dir.js";
import { existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { logToolDisplayDebug } from "./debug-logger.js";
import { isMcpToolCandidate } from "./tool-metadata.js";
import type { ToolDisplayConfig } from "./types.js";
export interface ToolDisplayCapabilities {
hasMcpTooling: boolean;
hasRtkOptimizer: boolean;
}
function hasMcpTooling(pi: ExtensionAPI): boolean {
try {
const allTools = pi.getAllTools();
return allTools.some((tool) => isMcpToolCandidate(tool));
} catch (error) {
logToolDisplayDebug("MCP capability detection failed.", error);
return false;
}
}
function hasRtkCommand(pi: ExtensionAPI): boolean {
try {
const commands = pi.getCommands();
return commands.some((command) => typeof command.name === "string" && (command.name === "rtk" || command.name.startsWith("rtk-")));
} catch (error) {
logToolDisplayDebug("RTK command capability detection failed.", error);
return false;
}
}
const rtkPathProbeCache = new Map<string, { fingerprint: string; exists: boolean }>();
function getPathFingerprint(path: string): string {
try {
const stats = statSync(path);
return `${stats.mtimeMs}:${stats.size}`;
} catch {
return "missing";
}
}
function cachedPathExists(path: string): boolean {
const fingerprint = getPathFingerprint(path);
const cached = rtkPathProbeCache.get(path);
if (cached && cached.fingerprint === fingerprint) {
return cached.exists;
}
let exists = false;
try {
exists = existsSync(path);
} catch (error) {
logToolDisplayDebug(`RTK capability path probe failed for ${path}.`, error);
}
rtkPathProbeCache.set(path, { fingerprint, exists });
return exists;
}
function hasRtkExtensionPath(cwd: string): boolean {
const candidates = [join(resolvePiAgentDir(), "extensions", "pi-rtk-optimizer"), join(cwd, ".pi", "extensions", "pi-rtk-optimizer")];
return candidates.some((candidate) => cachedPathExists(candidate));
}
export function detectToolDisplayCapabilities(pi: ExtensionAPI, cwd: string): ToolDisplayCapabilities {
return {
hasMcpTooling: hasMcpTooling(pi),
hasRtkOptimizer: hasRtkCommand(pi) || hasRtkExtensionPath(cwd),
};
}
export function applyCapabilityConfigGuards(
config: ToolDisplayConfig,
capabilities: ToolDisplayCapabilities,
): ToolDisplayConfig {
return {
...config,
registerToolOverrides: { ...config.registerToolOverrides },
mcpOutputMode: config.mcpOutputMode,
showRtkCompactionHints: capabilities.hasRtkOptimizer ? config.showRtkCompactionHints : false,
};
}

View File

@@ -0,0 +1,514 @@
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { ToolDisplayCapabilities } from "./capabilities.js";
import { getToolDisplayConfigPath } from "./config-store.js";
import {
detectToolDisplayPreset,
getToolDisplayPresetConfig,
parseToolDisplayPreset,
TOOL_DISPLAY_PRESETS,
type ToolDisplayPreset,
} from "./presets.js";
import { shortenPath } from "./render-utils.js";
import type { InspectorSettingItem } from "./settings-inspector-modal.js";
import { type ToolDisplayConfig } from "./types.js";
interface ToolDisplayConfigController {
getConfig(): ToolDisplayConfig;
setConfig(next: ToolDisplayConfig, ctx: ExtensionCommandContext): void;
getCapabilities(): ToolDisplayCapabilities;
}
interface ModalOverlayOptions {
anchor: "center";
width: number;
maxHeight: number;
margin: number;
}
const PREVIEW_LINE_VALUES = ["4", "8", "12", "20", "40"] as const;
const BASH_PREVIEW_LINE_VALUES = ["0", "5", "10", "20", "40"] as const;
const PRESET_COMMAND_HINT = TOOL_DISPLAY_PRESETS.join("|");
function toOnOff(value: boolean): string {
return value ? "on" : "off";
}
function toolOwnershipSummary(config: ToolDisplayConfig): string {
const overrides = config.registerToolOverrides;
return `read:${toOnOff(overrides.read)},grep:${toOnOff(overrides.grep)},find:${toOnOff(overrides.find)},ls:${toOnOff(overrides.ls)},bash:${toOnOff(overrides.bash)},edit:${toOnOff(overrides.edit)},write:${toOnOff(overrides.write)}`;
}
function summarizeConfig(config: ToolDisplayConfig, capabilities: ToolDisplayCapabilities): string {
const preset = detectToolDisplayPreset(config);
const parts = [
`preset=${preset}`,
`owners={${toolOwnershipSummary(config)}}`,
`userBox=${toOnOff(config.enableNativeUserMessageBox)}`,
`read=${config.readOutputMode}`,
`search=${config.searchOutputMode}`,
`preview=${config.previewLines}`,
`expandedMax=${config.expandedPreviewMaxLines}`,
`bash=${config.bashOutputMode}`,
`bashLines=${config.bashCollapsedLines}`,
`diff=${config.diffViewMode}/${config.diffIndicatorMode}@${config.diffSplitMinWidth}`,
`diffLines=${config.diffCollapsedLines}`,
`diffWrap=${toOnOff(config.diffWordWrap)}`,
];
if (capabilities.hasMcpTooling) {
parts.push(`mcp=${config.mcpOutputMode}`);
} else {
parts.push("mcp=auto-hidden");
}
if (capabilities.hasRtkOptimizer) {
parts.push(`rtkHints=${toOnOff(config.showRtkCompactionHints)}`);
} else {
parts.push("rtkHints=auto-off");
}
return parts.join(", ");
}
function parseNumber(value: string, fallback: number): number {
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
function buildAdvancedNotes(
config: ToolDisplayConfig,
capabilities: ToolDisplayCapabilities,
extra: readonly string[],
): string[] {
const notes = [
...extra,
"Manual JSON edits also expose registerToolOverrides.*, expandedPreviewMaxLines, diffSplitMinWidth, diffCollapsedLines, diffIndicatorMode, and diffWordWrap.",
`Tool ownership is currently ${toolOwnershipSummary(config)} and still applies after /reload.`,
`Truncation hints are ${toOnOff(config.showTruncationHints)}${capabilities.hasRtkOptimizer ? `; RTK hints are ${toOnOff(config.showRtkCompactionHints)}.` : "."}`,
];
return notes;
}
function buildInspectorSettings(
config: ToolDisplayConfig,
capabilities: ToolDisplayCapabilities,
): InspectorSettingItem[] {
const configPath = shortenPath(getToolDisplayConfigPath());
const items: InspectorSettingItem[] = [
{
id: "preset",
label: "Preset profile",
currentValue: detectToolDisplayPreset(config),
values: TOOL_DISPLAY_PRESETS,
inspectorTitle: "Preset Profile",
inspectorSummary: [
"Determines the overall verbosity and layout of the agent's tool output.",
"Choosing a preset applies a coherent profile across read, search, MCP, bash, and diff display settings.",
],
inspectorOptions: [
"opencode — strict inline-only tool output",
"balanced — compact summaries with counts",
"verbose — larger line previews and more visible bash output",
"custom — shown automatically when manual overrides no longer match a preset",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Presets reset multiple fields together, so manual JSON tuning is the right place for durable custom combinations.",
]),
inspectorPath: configPath,
searchTerms: ["verbosity", "profile", "layout", "custom", ...TOOL_DISPLAY_PRESETS],
},
{
id: "readOutputMode",
label: "Read tool output",
currentValue: config.readOutputMode,
values: ["hidden", "summary", "preview"],
inspectorTitle: "Read Tool Output",
inspectorSummary: [
"Controls how read results appear inline after the tool call header.",
"Use hidden for the cleanest transcript, summary for file metrics, or preview when seeing source lines matters in-context.",
],
inspectorOptions: [
"hidden — path and status only",
"summary — adds compact file metrics",
"preview — shows the first configured preview lines",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"expandedPreviewMaxLines bounds how many lines can appear after expanding a preview-heavy read result.",
]),
inspectorPath: configPath,
searchTerms: ["file", "source", "preview", "summary", "hidden"],
},
{
id: "searchOutputMode",
label: "Grep/Find/Ls output",
currentValue: config.searchOutputMode,
values: ["hidden", "count", "preview"],
inspectorTitle: "Grep / Find / Ls Output",
inspectorSummary: [
"Controls how search-style tools compress their result sets inside the transcript.",
"Count mode keeps discovery actions readable while still surfacing how much data the tool matched.",
],
inspectorOptions: [
"hidden — call header only",
"count — totals only for matches or entries",
"preview — shows the first configured preview lines",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Preview-heavy search output is most effective when paired with larger previewLines values in custom configurations.",
]),
inspectorPath: configPath,
searchTerms: ["grep", "find", "ls", "matches", "count", "results"],
},
];
if (capabilities.hasMcpTooling) {
items.push({
id: "mcpOutputMode",
label: "MCP tool output",
currentValue: config.mcpOutputMode,
values: ["hidden", "summary", "preview"],
inspectorTitle: "MCP Tool Output",
inspectorSummary: [
"Controls how proxied MCP tool results are compacted when they return text output.",
"Summary mode is the safest default when you want awareness without flooding the chat pane.",
],
inspectorOptions: [
"hidden — call metadata only",
"summary — compact line-count summary",
"preview — shows the first configured preview lines",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"This control appears only when MCP tooling is available in the current Pi session.",
]),
inspectorPath: configPath,
searchTerms: ["mcp", "proxy", "server", "summary", "preview"],
});
}
items.push(
{
id: "previewLines",
label: "Preview lines",
currentValue: String(config.previewLines),
values: PREVIEW_LINE_VALUES,
inspectorTitle: "Preview Lines",
inspectorSummary: [
"Sets how many lines appear when read, search, MCP, or bash preview modes are collapsed inline.",
"Accepted manual range: 1 to 80 lines. The quick selector cycles through a curated set for fast tuning.",
],
inspectorOptions: [
"Lower values keep transcripts dense and skimmable",
"Higher values surface more source context before expansion",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Pair this with expandedPreviewMaxLines when you want larger expanded previews without making collapsed output noisy.",
]),
inspectorPath: configPath,
searchTerms: ["preview", "lines", "range", "collapsed", "read", "grep", "mcp", "bash"],
},
{
id: "bashOutputMode",
label: "Bash tool output",
currentValue: config.bashOutputMode,
values: ["opencode", "summary", "preview"],
inspectorTitle: "Bash Tool Output",
inspectorSummary: [
"Controls how shell command output is rendered when the command finishes successfully.",
"The opencode mode keeps command output recognizable while still compressing walls of stdout.",
],
inspectorOptions: [
"opencode — Pi/OpenCode-style collapsed bash view",
"summary — output count only",
"preview — uses the shared previewLines setting",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Quiet commands still collapse aggressively, so mode selection matters most on verbose build, test, and script output.",
]),
inspectorPath: configPath,
searchTerms: ["bash", "shell", "stdout", "command", "opencode"],
},
{
id: "bashCollapsedLines",
label: "Bash collapsed lines",
currentValue: String(config.bashCollapsedLines),
values: BASH_PREVIEW_LINE_VALUES,
inspectorTitle: "Bash Collapsed Lines",
inspectorSummary: [
"Sets the inline line budget used specifically by opencode bash mode before expansion.",
"Accepted manual range: 0 to 80 lines. Setting 0 hides collapsed bash output entirely while keeping the command visible.",
],
inspectorOptions: [
"0 — hide collapsed bash output",
"5/10/20/40 — progressively larger inline command previews",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"This setting only changes the opencode bash renderer; preview mode continues to use previewLines instead.",
]),
inspectorPath: configPath,
searchTerms: ["bash", "collapsed", "lines", "stdout", "zero"],
},
{
id: "diffViewMode",
label: "Edit diff layout",
currentValue: config.diffViewMode,
values: ["auto", "split", "unified"],
inspectorTitle: "Edit Diff Layout",
inspectorSummary: [
"Controls how edit and write diffs are arranged when the extension renders code changes.",
"Auto mode adapts to terminal width so wide panes get side-by-side diffs while narrow panes stay readable.",
],
inspectorOptions: [
"auto — adaptive layout based on available width",
"split — force side-by-side diff columns",
"unified — force a single-column diff",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Manual JSON tuning exposes diffSplitMinWidth, diffCollapsedLines, diffIndicatorMode, and diffWordWrap for more aggressive diff control.",
]),
inspectorPath: configPath,
searchTerms: ["diff", "edit", "write", "split", "unified", "auto"],
},
{
id: "diffIndicatorMode",
label: "Diff indicators",
currentValue: config.diffIndicatorMode,
values: ["bars", "classic", "none"],
inspectorTitle: "Diff Indicators",
inspectorSummary: [
"Controls whether changed diff lines use vertical bars, classic +/- markers, or no indicators at all.",
"Bars continue across wrapped changed rows, classic markers appear only on the first wrapped row, and none removes the indicator column styling.",
],
inspectorOptions: [
"bars — persistent vertical indicators for changed rows",
"classic — + / - markers on the first visual row only",
"none — no diff indicator marker",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"Use config.json when you want this indicator preference to remain explicit alongside other diff rendering overrides.",
]),
inspectorPath: configPath,
searchTerms: ["diff", "indicator", "bars", "classic", "none", "marker"],
},
{
id: "enableNativeUserMessageBox",
label: "Native user message box",
currentValue: toOnOff(config.enableNativeUserMessageBox),
values: ["on", "off"],
inspectorTitle: "Native User Message Box",
inspectorSummary: [
"Toggles the bordered native renderer used for user prompts inside the Pi transcript.",
"Keep it on when you want clearer message separation, or turn it off to fall back to Pi's default user message rendering.",
],
inspectorOptions: [
"on — bordered native user prompt box",
"off — default Pi prompt rendering",
],
inspectorAdvanced: buildAdvancedNotes(config, capabilities, [
"This switch only affects presentation. It does not change stored prompts, markdown handling, or tool behavior.",
]),
inspectorPath: configPath,
searchTerms: ["user", "message", "box", "prompt", "native"],
},
);
return items;
}
function applyPreset(preset: ToolDisplayPreset): ToolDisplayConfig {
return getToolDisplayPresetConfig(preset);
}
function applySetting(config: ToolDisplayConfig, id: string, value: string): ToolDisplayConfig {
switch (id) {
case "preset": {
const parsed = parseToolDisplayPreset(value);
return parsed ? applyPreset(parsed) : config;
}
case "enableNativeUserMessageBox":
return {
...config,
enableNativeUserMessageBox: value === "on",
};
case "readOutputMode":
return {
...config,
readOutputMode: value as ToolDisplayConfig["readOutputMode"],
};
case "searchOutputMode":
return {
...config,
searchOutputMode: value as ToolDisplayConfig["searchOutputMode"],
};
case "mcpOutputMode":
return {
...config,
mcpOutputMode: value as ToolDisplayConfig["mcpOutputMode"],
};
case "previewLines":
return {
...config,
previewLines: parseNumber(value, config.previewLines),
};
case "bashOutputMode":
return {
...config,
bashOutputMode: value as ToolDisplayConfig["bashOutputMode"],
};
case "bashCollapsedLines":
return {
...config,
bashCollapsedLines: parseNumber(value, config.bashCollapsedLines),
};
case "diffViewMode":
return {
...config,
diffViewMode: value as ToolDisplayConfig["diffViewMode"],
};
case "diffIndicatorMode":
return {
...config,
diffIndicatorMode: value as ToolDisplayConfig["diffIndicatorMode"],
};
default:
return config;
}
}
function resolveResponsiveOverlayOptions(): ModalOverlayOptions {
const terminalWidth =
typeof process.stdout.columns === "number" && Number.isFinite(process.stdout.columns)
? process.stdout.columns
: 120;
const terminalHeight =
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
? process.stdout.rows
: 36;
const margin = 1;
const availableWidth = Math.max(72, terminalWidth - margin * 2);
const preferredWidth = terminalWidth >= 170 ? 128 : terminalWidth >= 145 ? 118 : terminalWidth >= 120 ? 106 : 92;
const width = Math.max(72, Math.min(preferredWidth, availableWidth));
const availableHeight = Math.max(14, terminalHeight - margin * 2);
const preferredHeight = Math.max(14, Math.floor(terminalHeight * 0.78));
const maxHeight = Math.min(preferredHeight, availableHeight);
return {
anchor: "center",
width,
maxHeight,
margin,
};
}
export async function openSettingsModal(ctx: ExtensionCommandContext, controller: ToolDisplayConfigController): Promise<void> {
const overlayOptions = resolveResponsiveOverlayOptions();
const capabilities = controller.getCapabilities();
const [{ ZellijModal }, { SplitPaneInspectorModal }] = await Promise.all([
import("./zellij-modal.js"),
import("./settings-inspector-modal.js"),
]);
await ctx.ui.custom<void>(
(tui, theme, _keybindings, done) => {
const inspector = new SplitPaneInspectorModal(
{
getSettings: () => buildInspectorSettings(controller.getConfig(), capabilities),
onChange: (id, newValue) => {
const next = applySetting(controller.getConfig(), id, newValue);
controller.setConfig(next, ctx);
},
onClose: () => done(),
},
theme,
);
const modal = new ZellijModal(
inspector,
{
borderStyle: "square",
padding: 0,
titleBar: {},
overlay: overlayOptions,
},
theme,
);
return {
render: (width: number) => modal.renderModal(width).lines,
invalidate: () => modal.invalidate(),
handleInput(data: string) {
modal.handleInput(data);
tui.requestRender();
},
};
},
{ overlay: true, overlayOptions },
);
}
export function handleToolDisplayArgs(args: string, ctx: ExtensionCommandContext, controller: ToolDisplayConfigController): boolean {
const raw = args.trim();
if (!raw) {
return false;
}
const normalized = raw.toLowerCase();
if (normalized === "show") {
ctx.ui.notify(
`tool-display: ${summarizeConfig(controller.getConfig(), controller.getCapabilities())}`,
"info",
);
return true;
}
if (normalized === "reset") {
controller.setConfig(getToolDisplayPresetConfig("opencode"), ctx);
ctx.ui.notify("Tool display preset reset to opencode.", "info");
return true;
}
if (normalized.startsWith("preset ")) {
const candidate = normalized.slice("preset ".length).trim();
const preset = parseToolDisplayPreset(candidate);
if (!preset) {
ctx.ui.notify(`Unknown preset. Use: /tool-display preset ${PRESET_COMMAND_HINT}`, "warning");
return true;
}
controller.setConfig(getToolDisplayPresetConfig(preset), ctx);
ctx.ui.notify(`Tool display preset set to ${preset}.`, "info");
return true;
}
ctx.ui.notify(`Usage: /tool-display [show|reset|preset ${PRESET_COMMAND_HINT}]`, "warning");
return true;
}
export async function runToolDisplayCommandHandler(
args: string,
ctx: ExtensionCommandContext,
controller: ToolDisplayConfigController,
): Promise<void> {
if (handleToolDisplayArgs(args, ctx, controller)) {
return;
}
if (!ctx.hasUI) {
ctx.ui.notify("/tool-display requires interactive TUI mode.", "warning");
return;
}
await openSettingsModal(ctx, controller);
}
export function registerToolDisplayCommand(pi: ExtensionAPI, controller: ToolDisplayConfigController): void {
pi.registerCommand("tool-display", {
description: "Configure tool output rendering (OpenCode-style)",
handler: async (args, ctx) => {
await runToolDisplayCommandHandler(args, ctx, controller);
},
});
}

View File

@@ -0,0 +1,303 @@
import { resolvePiAgentDir } from "./agent-dir.js";
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import {
BUILT_IN_TOOL_OVERRIDE_NAMES,
BASH_OUTPUT_MODES,
CUSTOM_TOOL_OUTPUT_MODES,
CUSTOM_TOOL_OVERRIDE_KINDS,
DEFAULT_TOOL_DISPLAY_CONFIG,
type ConfigLoadResult,
type ConfigSaveResult,
type CustomToolOverrideConfig,
DIFF_INDICATOR_MODES,
DIFF_VIEW_MODES,
MCP_OUTPUT_MODES,
READ_OUTPUT_MODES,
SEARCH_OUTPUT_MODES,
type ToolDisplayConfig,
type ToolOverrideOwnership,
} from "./types.js";
import { toRecord } from "./tool-metadata.js";
const CONFIG_DIR = join(resolvePiAgentDir(), "extensions", "pi-tool-display");
const CONFIG_FILE = join(CONFIG_DIR, "config.json");
interface LegacyToolDisplayConfigSource extends Partial<ToolDisplayConfig> {
registerReadToolOverride?: unknown;
}
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
if (typeof value !== "number" || Number.isNaN(value)) {
return fallback;
}
const rounded = Math.floor(value);
if (rounded < min) return min;
if (rounded > max) return max;
return rounded;
}
function toBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function toReadOutputMode(value: unknown): ToolDisplayConfig["readOutputMode"] {
return READ_OUTPUT_MODES.includes(value as ToolDisplayConfig["readOutputMode"])
? (value as ToolDisplayConfig["readOutputMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.readOutputMode;
}
function toSearchOutputMode(value: unknown): ToolDisplayConfig["searchOutputMode"] {
return SEARCH_OUTPUT_MODES.includes(value as ToolDisplayConfig["searchOutputMode"])
? (value as ToolDisplayConfig["searchOutputMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.searchOutputMode;
}
function toMcpOutputMode(value: unknown): ToolDisplayConfig["mcpOutputMode"] {
return MCP_OUTPUT_MODES.includes(value as ToolDisplayConfig["mcpOutputMode"])
? (value as ToolDisplayConfig["mcpOutputMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.mcpOutputMode;
}
function toBashOutputMode(value: unknown): ToolDisplayConfig["bashOutputMode"] {
return BASH_OUTPUT_MODES.includes(value as ToolDisplayConfig["bashOutputMode"])
? (value as ToolDisplayConfig["bashOutputMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.bashOutputMode;
}
function toDiffViewMode(value: unknown): ToolDisplayConfig["diffViewMode"] {
if (value === "stacked") {
// Backward compatibility with older config naming.
return "unified";
}
return DIFF_VIEW_MODES.includes(value as ToolDisplayConfig["diffViewMode"])
? (value as ToolDisplayConfig["diffViewMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.diffViewMode;
}
function toDiffIndicatorMode(value: unknown): ToolDisplayConfig["diffIndicatorMode"] {
return DIFF_INDICATOR_MODES.includes(value as ToolDisplayConfig["diffIndicatorMode"])
? (value as ToolDisplayConfig["diffIndicatorMode"])
: DEFAULT_TOOL_DISPLAY_CONFIG.diffIndicatorMode;
}
export function cloneCustomToolOverrides(
overrides: Record<string, CustomToolOverrideConfig>,
): Record<string, CustomToolOverrideConfig> {
return Object.fromEntries(
Object.entries(overrides).map(([toolName, override]) => [
toolName,
{ ...override },
]),
);
}
function cloneDefaultConfig(): ToolDisplayConfig {
return {
...DEFAULT_TOOL_DISPLAY_CONFIG,
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
customToolOverrides: cloneCustomToolOverrides(DEFAULT_TOOL_DISPLAY_CONFIG.customToolOverrides),
};
}
let cachedConfigFile: string | undefined;
let cachedConfigFingerprint: string | undefined;
let cachedConfigResult: ConfigLoadResult | undefined;
function cloneConfig(config: ToolDisplayConfig): ToolDisplayConfig {
return normalizeToolDisplayConfig(config);
}
function cloneLoadResult(result: ConfigLoadResult): ConfigLoadResult {
return {
...result,
config: cloneConfig(result.config),
};
}
function getConfigFingerprint(configFile: string): string {
try {
const stats = statSync(configFile);
return `${stats.mtimeMs}:${stats.size}`;
} catch {
return "missing";
}
}
function normalizeToolOverrideOwnership(
rawOverrides: unknown,
legacyRegisterReadToolOverride: unknown,
): ToolOverrideOwnership {
const source = toRecord(rawOverrides);
const defaults = DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides;
const legacyReadDefault = toBoolean(legacyRegisterReadToolOverride, defaults.read);
const overrides = { ...defaults };
for (const toolName of BUILT_IN_TOOL_OVERRIDE_NAMES) {
const fallback = toolName === "read" ? legacyReadDefault : defaults[toolName];
overrides[toolName] = toBoolean(source[toolName], fallback);
}
return overrides;
}
function isBuiltInToolOverrideName(toolName: string): boolean {
return (BUILT_IN_TOOL_OVERRIDE_NAMES as readonly string[]).includes(toolName);
}
export function toCustomToolOverrideKind(value: unknown): CustomToolOverrideConfig["kind"] {
return CUSTOM_TOOL_OVERRIDE_KINDS.includes(value as CustomToolOverrideConfig["kind"])
? (value as CustomToolOverrideConfig["kind"])
: "generic";
}
export function toCustomToolOutputMode(value: unknown): CustomToolOverrideConfig["outputMode"] {
return CUSTOM_TOOL_OUTPUT_MODES.includes(value as CustomToolOverrideConfig["outputMode"])
? (value as CustomToolOverrideConfig["outputMode"])
: "summary";
}
export function normalizeCustomToolOverrideEntry(rawEntry: unknown): CustomToolOverrideConfig | undefined {
if (typeof rawEntry === "boolean") {
return {
enabled: rawEntry,
kind: "generic",
outputMode: "summary",
};
}
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
return undefined;
}
const source = toRecord(rawEntry);
return {
enabled: toBoolean(source.enabled, true),
kind: toCustomToolOverrideKind(source.kind),
outputMode: toCustomToolOutputMode(source.outputMode),
};
}
function normalizeCustomToolOverrides(rawOverrides: unknown): Record<string, CustomToolOverrideConfig> {
const source = toRecord(rawOverrides);
const overrides: Record<string, CustomToolOverrideConfig> = {};
for (const [rawToolName, rawEntry] of Object.entries(source)) {
const toolName = rawToolName.trim();
if (!toolName || isBuiltInToolOverrideName(toolName)) {
continue;
}
const normalized = normalizeCustomToolOverrideEntry(rawEntry);
if (!normalized) {
continue;
}
overrides[toolName] = normalized;
}
return overrides;
}
export function normalizeToolDisplayConfig(raw: unknown): ToolDisplayConfig {
const source =
typeof raw === "object" && raw !== null ? (raw as LegacyToolDisplayConfigSource) : ({} as LegacyToolDisplayConfigSource);
return {
enabled: toBoolean(source.enabled, DEFAULT_TOOL_DISPLAY_CONFIG.enabled),
registerToolOverrides: normalizeToolOverrideOwnership(
source.registerToolOverrides,
source.registerReadToolOverride,
),
customToolOverrides: normalizeCustomToolOverrides(source.customToolOverrides),
enableNativeUserMessageBox: toBoolean(
source.enableNativeUserMessageBox,
DEFAULT_TOOL_DISPLAY_CONFIG.enableNativeUserMessageBox,
),
readOutputMode: toReadOutputMode(source.readOutputMode),
searchOutputMode: toSearchOutputMode(source.searchOutputMode),
mcpOutputMode: toMcpOutputMode(source.mcpOutputMode),
previewLines: clampNumber(source.previewLines, 1, 80, DEFAULT_TOOL_DISPLAY_CONFIG.previewLines),
expandedPreviewMaxLines: clampNumber(
source.expandedPreviewMaxLines,
0,
20_000,
DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
),
bashOutputMode: toBashOutputMode(source.bashOutputMode),
bashCollapsedLines: clampNumber(source.bashCollapsedLines, 0, 80, DEFAULT_TOOL_DISPLAY_CONFIG.bashCollapsedLines),
diffViewMode: toDiffViewMode(source.diffViewMode),
diffIndicatorMode: toDiffIndicatorMode(source.diffIndicatorMode),
diffSplitMinWidth: clampNumber(source.diffSplitMinWidth, 70, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffSplitMinWidth),
diffCollapsedLines: clampNumber(source.diffCollapsedLines, 4, 240, DEFAULT_TOOL_DISPLAY_CONFIG.diffCollapsedLines),
diffWordWrap: toBoolean(source.diffWordWrap, DEFAULT_TOOL_DISPLAY_CONFIG.diffWordWrap),
showTruncationHints: toBoolean(source.showTruncationHints, DEFAULT_TOOL_DISPLAY_CONFIG.showTruncationHints),
showRtkCompactionHints: toBoolean(
source.showRtkCompactionHints,
DEFAULT_TOOL_DISPLAY_CONFIG.showRtkCompactionHints,
),
};
}
export function loadToolDisplayConfig(configFile = CONFIG_FILE): ConfigLoadResult {
const fingerprint = getConfigFingerprint(configFile);
if (cachedConfigResult && cachedConfigFile === configFile && cachedConfigFingerprint === fingerprint) {
return cloneLoadResult(cachedConfigResult);
}
let result: ConfigLoadResult;
if (!existsSync(configFile)) {
result = { config: cloneDefaultConfig() };
} else {
try {
const rawText = readFileSync(configFile, "utf-8");
const rawConfig = JSON.parse(rawText) as unknown;
result = { config: normalizeToolDisplayConfig(rawConfig) };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result = {
config: cloneDefaultConfig(),
error: `Failed to parse ${configFile}: ${message}`,
};
}
}
cachedConfigFile = configFile;
cachedConfigFingerprint = fingerprint;
cachedConfigResult = cloneLoadResult(result);
return result;
}
export function saveToolDisplayConfig(config: ToolDisplayConfig, configFile = CONFIG_FILE): ConfigSaveResult {
const normalized = normalizeToolDisplayConfig(config);
const tmpFile = `${configFile}.tmp`;
try {
mkdirSync(dirname(configFile), { recursive: true });
writeFileSync(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, "utf-8");
renameSync(tmpFile, configFile);
cachedConfigFile = undefined;
cachedConfigFingerprint = undefined;
cachedConfigResult = undefined;
return { success: true };
} catch (error) {
try {
if (existsSync(tmpFile)) {
unlinkSync(tmpFile);
}
} catch (cleanupError) {
// Ignore cleanup errors.
void cleanupError;
}
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to save ${configFile}: ${message}`,
};
}
}
export function getToolDisplayConfigPath(): string {
return CONFIG_FILE;
}

View File

@@ -0,0 +1,150 @@
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
import { appendFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { toRecord } from "./tool-metadata.js";
const EXTENSION_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const DEFAULT_DEBUG_CONFIG_FILE = join(EXTENSION_ROOT, "config.json");
const DEFAULT_DEBUG_DIR = join(EXTENSION_ROOT, "debug");
const DEFAULT_DEBUG_LOG_FILE = join(DEFAULT_DEBUG_DIR, "debug.log");
const DEFAULT_DEBUG_CONFIG_CACHE_TTL_MS = 1_000;
const SECRET_VALUE_PATTERN = /\b(?:sk-[A-Za-z0-9_-]{12,}|[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{12,})\b/g;
interface ToolDisplayDebugLoggerFileSystem {
existsSync: typeof existsSync;
mkdirSync: typeof mkdirSync;
readFileSync: typeof readFileSync;
statSync: typeof statSync;
appendFile: typeof appendFile;
}
export interface ToolDisplayDebugLoggerOptions {
configFile?: string;
debugDir?: string;
debugLogFile?: string;
cacheTtlMs?: number;
now?: () => number;
createDate?: () => Date;
fileSystem?: ToolDisplayDebugLoggerFileSystem;
}
export interface ToolDisplayDebugLogger {
log(message: string, error?: unknown): void;
flush(): Promise<void>;
}
const DEFAULT_FILE_SYSTEM: ToolDisplayDebugLoggerFileSystem = {
existsSync,
mkdirSync,
readFileSync,
statSync,
appendFile,
};
function redactMessage(value: string): string {
return value.replace(SECRET_VALUE_PATTERN, "[REDACTED]");
}
export function createToolDisplayDebugLogger(options: ToolDisplayDebugLoggerOptions = {}): ToolDisplayDebugLogger {
const configFile = options.configFile ?? DEFAULT_DEBUG_CONFIG_FILE;
const debugDir = options.debugDir ?? DEFAULT_DEBUG_DIR;
const debugLogFile = options.debugLogFile ?? DEFAULT_DEBUG_LOG_FILE;
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_DEBUG_CONFIG_CACHE_TTL_MS;
const now = options.now ?? Date.now;
const createDate = options.createDate ?? (() => new Date());
const fileSystem = options.fileSystem ?? DEFAULT_FILE_SYSTEM;
let cachedDebugFingerprint: string | undefined;
let cachedDebugEnabled = false;
let cachedDebugCheckedAt = 0;
let debugDirectoryReady = false;
let writeQueue: Promise<void> = Promise.resolve();
function getDebugConfigFingerprint(): string {
try {
const stats = fileSystem.statSync(configFile);
return `${stats.mtimeMs}:${stats.size}`;
} catch {
return "missing";
}
}
function isDebugEnabled(): boolean {
const checkedAt = now();
if (cachedDebugFingerprint !== undefined && checkedAt - cachedDebugCheckedAt < cacheTtlMs) {
return cachedDebugEnabled;
}
cachedDebugCheckedAt = checkedAt;
const fingerprint = getDebugConfigFingerprint();
if (fingerprint === cachedDebugFingerprint) {
return cachedDebugEnabled;
}
cachedDebugFingerprint = fingerprint;
cachedDebugEnabled = false;
try {
if (!fileSystem.existsSync(configFile)) {
return cachedDebugEnabled;
}
const rawConfig = JSON.parse(fileSystem.readFileSync(configFile, "utf8") as string) as unknown;
cachedDebugEnabled = toRecord(rawConfig).debug === true;
return cachedDebugEnabled;
} catch {
return cachedDebugEnabled;
}
}
function ensureDebugDirectory(): void {
if (debugDirectoryReady) {
return;
}
fileSystem.mkdirSync(debugDir, { recursive: true });
debugDirectoryReady = true;
}
function appendLine(line: string): Promise<void> {
return fileSystem.appendFile(debugLogFile, line, "utf8");
}
return {
log(message: string, error?: unknown): void {
if (!isDebugEnabled()) {
return;
}
try {
ensureDebugDirectory();
const errorText = error instanceof Error
? `${error.name}: ${error.message}`
: error === undefined
? ""
: String(error);
const suffix = errorText ? ` ${redactMessage(errorText)}` : "";
const line = `${createDate().toISOString()} ${redactMessage(message)}${suffix}\n`;
writeQueue = writeQueue.then(
() => appendLine(line),
() => appendLine(line),
);
void writeQueue.catch(() => undefined);
} catch (logError) {
// Debug logging must never affect extension behavior.
void logError;
}
},
flush(): Promise<void> {
return writeQueue.catch(() => undefined);
},
};
}
const defaultDebugLogger = createToolDisplayDebugLogger();
export function logToolDisplayDebug(message: string, error?: unknown): void {
defaultDebugLogger.log(message, error);
}

View File

@@ -0,0 +1,72 @@
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { pluralize } from "./render-utils.js";
import type { ToolDisplayConfig } from "./types.js";
export interface DiffSummaryStats {
added: number;
removed: number;
hunks: number;
files: number;
}
export type DiffPresentationMode = "split" | "unified" | "compact" | "summary";
const MIN_COMPACT_DIFF_WIDTH = 8;
const MIN_UNIFIED_DIFF_WIDTH = 18;
export function normalizeDiffRenderWidth(width: number): number {
if (!Number.isFinite(width)) {
return 0;
}
return Math.max(0, Math.floor(width));
}
export function resolveDiffPresentationMode(
config: Pick<ToolDisplayConfig, "diffViewMode" | "diffSplitMinWidth">,
width: number,
canRenderSplitLayout: boolean,
): DiffPresentationMode {
const safeWidth = normalizeDiffRenderWidth(width);
if (safeWidth < MIN_COMPACT_DIFF_WIDTH) {
return "summary";
}
if (safeWidth < MIN_UNIFIED_DIFF_WIDTH) {
return "compact";
}
switch (config.diffViewMode) {
case "split":
return canRenderSplitLayout ? "split" : "unified";
case "unified":
return "unified";
case "auto":
default:
return safeWidth >= config.diffSplitMinWidth && canRenderSplitLayout
? "split"
: "unified";
}
}
export function buildDiffSummaryText(stats: DiffSummaryStats, width: number): string {
const safeWidth = normalizeDiffRenderWidth(width);
if (safeWidth === 0) {
return "";
}
const summaryCandidates = [
`↳ diff +${stats.added} -${stats.removed}${stats.hunks} ${pluralize(stats.hunks, "hunk")}${stats.files} ${pluralize(stats.files, "file")}`,
`↳ diff +${stats.added} -${stats.removed}${stats.hunks}h • ${stats.files}f`,
`↳ diff +${stats.added} -${stats.removed}`,
`+${stats.added} -${stats.removed}`,
"diff",
"…",
];
for (const candidate of summaryCandidates) {
if (visibleWidth(candidate) <= safeWidth) {
return candidate;
}
}
return truncateToWidth(summaryCandidates[summaryCandidates.length - 1] ?? "", safeWidth, "");
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
// Track cleanup callbacks for reload safety
let cleanupCallbacks: Array<() => void> = [];
let isDisposed = false;
export function registerCleanup(callback: () => void): void {
if (isDisposed) {
callback();
return;
}
cleanupCallbacks.push(callback);
}
export function registerTimer(timer: ReturnType<typeof setInterval> | ReturnType<typeof setTimeout>): void {
registerCleanup(() => clearInterval(timer as ReturnType<typeof setInterval>));
}
export function disposeAll(): void {
if (isDisposed) return;
isDisposed = true;
// Run in reverse order (LIFO)
for (let i = cleanupCallbacks.length - 1; i >= 0; i--) {
try { cleanupCallbacks[i](); } catch (cleanupError) { void cleanupError; }
}
cleanupCallbacks = [];
}
export function resetDisposed(): void {
isDisposed = false;
cleanupCallbacks = [];
}
export function getCleanupCount(): number {
return cleanupCallbacks.length;
}

View File

@@ -0,0 +1,14 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
/**
* Register a session_shutdown handler that runs cleanup only when the
* session is shutting down for a reload. Centralizes the reload-detection
* check shared by extension registration modules.
*/
export function onReloadShutdown(pi: ExtensionAPI, cleanup: () => void): void {
pi.on("session_shutdown", async (event: { reason?: string }) => {
if (event?.reason === "reload") {
cleanup();
}
});
}

View File

@@ -0,0 +1,109 @@
import type {
ExtensionAPI,
ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import {
loadToolDisplayConfig,
normalizeToolDisplayConfig,
saveToolDisplayConfig,
} from "./config-store.js";
import {
applyCapabilityConfigGuards,
detectToolDisplayCapabilities,
type ToolDisplayCapabilities,
} from "./capabilities.js";
import { registerToolDisplayOverrides } from "./tool-overrides.js";
import { disposeAll, resetDisposed } from "./disposable.js";
import { registerThinkingLabeling } from "./thinking-label.js";
import registerNativeUserMessageBox from "./user-message-box-native.js";
import {
BUILT_IN_TOOL_OVERRIDE_NAMES,
type ToolDisplayConfig,
} from "./types.js";
function ownershipChanged(
previous: ToolDisplayConfig,
next: ToolDisplayConfig,
): boolean {
return BUILT_IN_TOOL_OVERRIDE_NAMES.some(
(toolName) =>
previous.registerToolOverrides[toolName] !==
next.registerToolOverrides[toolName],
);
}
export default function toolDisplayExtension(pi: ExtensionAPI): void {
const initial = loadToolDisplayConfig();
if (!initial.config.enabled) {
return;
}
resetDisposed();
pi.on("session_shutdown", (event: { reason: string }) => {
if (event.reason === "reload") {
disposeAll();
}
});
let config: ToolDisplayConfig = initial.config;
let pendingLoadError = initial.error;
let capabilities: ToolDisplayCapabilities = {
hasMcpTooling: false,
hasRtkOptimizer: false,
};
const refreshCapabilities = (): void => {
capabilities = detectToolDisplayCapabilities(pi, process.cwd());
};
const getConfig = (): ToolDisplayConfig => config;
const getCapabilities = (): ToolDisplayCapabilities => capabilities;
const getEffectiveConfig = (): ToolDisplayConfig =>
applyCapabilityConfigGuards(config, capabilities);
const setConfig = (
next: ToolDisplayConfig,
ctx: ExtensionCommandContext,
): void => {
const normalized = normalizeToolDisplayConfig(next);
const requiresReload = ownershipChanged(config, normalized);
config = normalized;
const saved = saveToolDisplayConfig(normalized);
if (!saved.success && saved.error) {
ctx.ui.notify(saved.error, "error");
}
if (requiresReload) {
ctx.ui.notify(
"Tool ownership updates apply after /reload.",
"warning",
);
}
};
registerToolDisplayOverrides(pi, getEffectiveConfig);
registerNativeUserMessageBox(pi, getConfig);
registerThinkingLabeling(pi);
pi.registerCommand("tool-display", {
description: "Configure tool output rendering (OpenCode-style)",
handler: async (args, ctx) => {
const { runToolDisplayCommandHandler } = await import("./config-modal.js");
await runToolDisplayCommandHandler(args, ctx, { getConfig, setConfig, getCapabilities });
},
});
pi.on("session_start", async (_event, ctx) => {
refreshCapabilities();
if (pendingLoadError) {
ctx.ui.notify(pendingLoadError, "warning");
pendingLoadError = undefined;
}
});
pi.on("before_agent_start", async () => {
refreshCapabilities();
});
}

View File

@@ -0,0 +1,93 @@
import { normalizeDiffRenderWidth } from "./diff-presentation.js";
import { pluralize } from "./render-utils.js";
export interface WidthMeasurementOps {
measure(text: string): number;
truncate(text: string, maxWidth: number): string;
}
export interface CollapsedDiffHintOptions {
remainingLines: number;
hiddenHunks: number;
}
function guardSafeWidth(width: number): number | undefined {
const safe = normalizeDiffRenderWidth(width);
return safe === 0 ? undefined : safe;
}
function renderWithSafeWidth<T>(
width: number,
fallback: T,
render: (safeWidth: number) => T,
): T {
const safeWidth = guardSafeWidth(width);
if (safeWidth === undefined) {
return fallback;
}
return render(safeWidth);
}
export function clampRenderedLineToWidth(
text: string,
width: number,
ops: WidthMeasurementOps,
): string {
return renderWithSafeWidth(width, "", (safeWidth) => {
if (ops.measure(text) <= safeWidth) {
return text;
}
for (let targetWidth = safeWidth; targetWidth >= 0; targetWidth--) {
const candidate = ops.truncate(text, targetWidth);
if (ops.measure(candidate) <= safeWidth) {
return candidate;
}
}
return "";
});
}
export function clampRenderedLinesToWidth(
lines: string[],
width: number,
ops: WidthMeasurementOps,
): string[] {
return lines.map((line) => clampRenderedLineToWidth(line, width, ops));
}
export function buildCollapsedDiffHintText(
options: CollapsedDiffHintOptions,
width: number,
ops: WidthMeasurementOps,
): string {
return renderWithSafeWidth(width, "", (safeWidth) => {
const remainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "diff line")}`;
const hiddenHunksText = options.hiddenHunks > 0
? `${options.hiddenHunks} more ${pluralize(options.hiddenHunks, "hunk")}`
: undefined;
const shortRemainingText = `${options.remainingLines} more ${pluralize(options.remainingLines, "line")}`;
const shortHiddenHunksText = options.hiddenHunks > 0
? `${options.hiddenHunks} ${pluralize(options.hiddenHunks, "hunk")}`
: undefined;
const candidates = [
`… (${[remainingText, hiddenHunksText, "Ctrl+O to expand"].filter(Boolean).join(" • ")})`,
`… (${[remainingText, hiddenHunksText].filter(Boolean).join(" • ")})`,
`… (${[shortRemainingText, shortHiddenHunksText].filter(Boolean).join(" • ")})`,
options.hiddenHunks > 0
? `… (+${options.remainingLines} • +${options.hiddenHunks}h)`
: `… (+${options.remainingLines})`,
"…",
];
for (const candidate of candidates) {
if (ops.measure(candidate) <= safeWidth) {
return candidate;
}
}
return clampRenderedLineToWidth(candidates[candidates.length - 1] ?? "", safeWidth, ops);
});
}

View File

@@ -0,0 +1,57 @@
export interface ModalIconSet {
search: string;
}
const NERD_MODAL_ICONS: ModalIconSet = {
search: "\uF002",
};
const EMOJI_MODAL_ICONS: ModalIconSet = {
search: "🔍",
};
const NERD_FONT_TERMINAL_HINTS = [
"iterm",
"wezterm",
"kitty",
"ghostty",
"alacritty",
"xfce4-terminal",
"gnome-terminal",
"tilix",
"terminator",
"konsole",
] as const;
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === "1" || value?.toLowerCase() === "true") {
return true;
}
if (value === "0" || value?.toLowerCase() === "false") {
return false;
}
return undefined;
}
function detectNerdFonts(): boolean {
const explicitPreference =
parseBooleanEnv(process.env.PI_NERD_FONTS) ?? parseBooleanEnv(process.env.POWERLINE_NERD_FONTS);
if (explicitPreference !== undefined) {
return explicitPreference;
}
if (process.env.GHOSTTY_RESOURCES_DIR) {
return true;
}
const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
const term = (process.env.TERM || "").toLowerCase();
const fingerprint = `${termProgram} ${term}`;
return NERD_FONT_TERMINAL_HINTS.some((terminal) => fingerprint.includes(terminal));
}
export function getModalIcons(): ModalIconSet {
return detectNerdFonts() ? NERD_MODAL_ICONS : EMOJI_MODAL_ICONS;
}

View File

@@ -0,0 +1,370 @@
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, relative, resolve } from "node:path";
export interface PendingDiffPreviewData {
filePath: string;
previousContent?: string;
nextContent?: string;
fileExistedBeforeWrite: boolean;
headerLabel: string;
notice?: string;
}
type EditPreviewInput = {
path?: unknown;
file_path?: unknown;
oldText?: unknown;
newText?: unknown;
edits?: unknown;
};
type EditReplacement = {
oldText: string;
newText: string;
};
type FileReadResult = {
exists: boolean;
content?: string;
error?: string;
};
const MAX_PREVIEW_READ_BYTES = 1_000_000;
type ProjectedEditResult =
| {
ok: true;
content: string;
}
| {
ok: false;
reason: string;
};
function trimPath(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
function resolvePreviewPath(cwd: string, rawPath: string): string {
const trimmed = rawPath.trim();
if (!trimmed) {
return cwd;
}
const expandedHome = trimmed === "~"
? homedir()
: trimmed.startsWith("~/") || trimmed.startsWith("~\\")
? `${homedir()}${trimmed.slice(1)}`
: trimmed;
return isAbsolute(expandedHome) ? expandedHome : resolve(cwd, expandedHome);
}
function isWithinWorkspace(workspacePath: string, targetPath: string): boolean {
const relativePath = relative(workspacePath, targetPath);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function safeRealpath(path: string): string {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
}
function resolveWorkspaceReadPath(cwd: string, rawPath: string): { resolvedPath: string; error?: string } {
const workspacePath = safeRealpath(cwd);
const resolvedPath = resolvePreviewPath(cwd, rawPath);
if (!isWithinWorkspace(workspacePath, resolvedPath)) {
return {
resolvedPath,
error: "Preview unavailable because the target path is outside the current workspace.",
};
}
if (!existsSync(resolvedPath)) {
return { resolvedPath };
}
try {
const targetPath = realpathSync(resolvedPath);
if (!isWithinWorkspace(workspacePath, targetPath)) {
return {
resolvedPath,
error: "Preview unavailable because the target path resolves outside the current workspace.",
};
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
resolvedPath,
error: `Unable to resolve '${resolvedPath}': ${message}`,
};
}
return { resolvedPath };
}
export function readWorkspaceUtf8File(cwd: string, rawPath: string): FileReadResult {
const safePath = resolveWorkspaceReadPath(cwd, rawPath);
if (safePath.error) {
return { exists: false, error: safePath.error };
}
if (!existsSync(safePath.resolvedPath)) {
return { exists: false };
}
try {
const stats = statSync(safePath.resolvedPath);
if (!stats.isFile()) {
return {
exists: true,
error: `Preview unavailable because '${safePath.resolvedPath}' is not a regular file.`,
};
}
if (stats.size > MAX_PREVIEW_READ_BYTES) {
return {
exists: true,
error: `Preview unavailable because '${safePath.resolvedPath}' exceeds the ${MAX_PREVIEW_READ_BYTES} byte preview read limit.`,
};
}
return {
exists: true,
content: readFileSync(safePath.resolvedPath, "utf8"),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
exists: true,
error: `Unable to read '${safePath.resolvedPath}': ${message}`,
};
}
}
function countSubstringMatches(haystack: string, needle: string): number {
if (!needle) {
return 0;
}
let count = 0;
let cursor = 0;
while (cursor <= haystack.length) {
const index = haystack.indexOf(needle, cursor);
if (index === -1) {
break;
}
count++;
cursor = index + 1;
}
return count;
}
function stripBom(content: string): { bom: string; text: string } {
return content.startsWith("\uFEFF")
? { bom: "\uFEFF", text: content.slice(1) }
: { bom: "", text: content };
}
function detectLineEnding(content: string): "\r\n" | "\n" {
const crlfIndex = content.indexOf("\r\n");
const lfIndex = content.indexOf("\n");
if (lfIndex === -1 || crlfIndex === -1) {
return "\n";
}
return crlfIndex < lfIndex ? "\r\n" : "\n";
}
function normalizeToLf(content: string): string {
return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
function restoreLineEndings(content: string, ending: "\r\n" | "\n"): string {
return ending === "\r\n" ? content.replace(/\n/g, "\r\n") : content;
}
function toEditInput(value: unknown): EditPreviewInput {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
return value as EditPreviewInput;
}
function getToolPath(input: unknown, preferFilePath: boolean): string | undefined {
const record = toEditInput(input);
const filePath = trimPath(record.file_path);
const path = trimPath(record.path);
return preferFilePath ? filePath ?? path : path ?? filePath;
}
function getWriteContent(input: unknown): string | undefined {
const record = toEditInput(input);
return typeof record.newText === "string"
? undefined
: typeof (record as { content?: unknown }).content === "string"
? (record as { content: string }).content
: undefined;
}
function getEditReplacements(input: unknown): EditReplacement[] {
const record = toEditInput(input);
if (Array.isArray(record.edits)) {
return record.edits.flatMap((entry) => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return [];
}
const edit = entry as { oldText?: unknown; newText?: unknown };
return typeof edit.oldText === "string" && typeof edit.newText === "string"
? [{ oldText: edit.oldText, newText: edit.newText }]
: [];
});
}
return typeof record.oldText === "string" && typeof record.newText === "string"
? [{ oldText: record.oldText, newText: record.newText }]
: [];
}
function buildProjectedEditContent(originalContent: string, replacements: readonly EditReplacement[]): ProjectedEditResult {
if (replacements.length === 0) {
return {
ok: false,
reason: "Preview not shown: the edit request did not include exact replacement blocks.",
};
}
const { bom, text } = stripBom(originalContent);
const originalLineEnding = detectLineEnding(text);
const normalizedContent = normalizeToLf(text);
const normalizedReplacements = replacements.map((replacement) => ({
oldText: normalizeToLf(replacement.oldText),
newText: normalizeToLf(replacement.newText),
}));
const ranges: Array<{ start: number; end: number; replacement: string }> = [];
for (const [index, replacement] of normalizedReplacements.entries()) {
if (!replacement.oldText) {
return {
ok: false,
reason: `Preview not shown: edit #${index + 1} has an empty oldText block.`,
};
}
const matchCount = countSubstringMatches(normalizedContent, replacement.oldText);
if (matchCount !== 1) {
return {
ok: false,
reason: matchCount === 0
? `Preview not shown: edit #${index + 1} did not match the current file contents.`
: `Preview not shown: edit #${index + 1} matched ${matchCount} regions instead of exactly one.`,
};
}
const start = normalizedContent.indexOf(replacement.oldText);
ranges.push({
start,
end: start + replacement.oldText.length,
replacement: replacement.newText,
});
}
ranges.sort((left, right) => left.start - right.start);
for (let index = 1; index < ranges.length; index++) {
const previous = ranges[index - 1];
const current = ranges[index];
if (previous && current && current.start < previous.end) {
return {
ok: false,
reason: "Preview not shown: the requested edits overlap in the original file.",
};
}
}
let cursor = 0;
let output = "";
for (const range of ranges) {
output += normalizedContent.slice(cursor, range.start);
output += range.replacement;
cursor = range.end;
}
output += normalizedContent.slice(cursor);
return {
ok: true,
content: `${bom}${restoreLineEndings(output, originalLineEnding)}`,
};
}
export function buildPendingWritePreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
const filePath = getToolPath(input, false);
const nextContent = getWriteContent(input);
if (!filePath || typeof nextContent !== "string") {
return undefined;
}
const existing = readWorkspaceUtf8File(cwd, filePath);
return {
filePath,
previousContent: existing.content,
nextContent,
fileExistedBeforeWrite: existing.exists,
headerLabel: existing.exists ? "pending overwrite" : "pending create",
notice: existing.error,
};
}
export function buildPendingEditPreviewData(input: unknown, cwd: string): PendingDiffPreviewData | undefined {
const filePath = getToolPath(input, true);
if (!filePath) {
return undefined;
}
const existing = readWorkspaceUtf8File(cwd, filePath);
if (existing.error) {
return {
filePath,
fileExistedBeforeWrite: false,
headerLabel: "pending edit",
notice: existing.error,
};
}
if (!existing.exists || typeof existing.content !== "string") {
return {
filePath,
fileExistedBeforeWrite: false,
headerLabel: "pending edit",
notice: "Preview unavailable because the target file does not exist yet.",
};
}
const projected = buildProjectedEditContent(existing.content, getEditReplacements(input));
if (!projected.ok) {
const failedProjection = projected as Extract<ProjectedEditResult, { ok: false }>;
return {
filePath,
previousContent: existing.content,
fileExistedBeforeWrite: true,
headerLabel: "pending edit",
notice: failedProjection.reason,
};
}
return {
filePath,
previousContent: existing.content,
nextContent: projected.content,
fileExistedBeforeWrite: true,
headerLabel: "pending edit",
};
}

View File

@@ -0,0 +1,108 @@
import { cloneCustomToolOverrides } from "./config-store.js";
import { DEFAULT_TOOL_DISPLAY_CONFIG, type CustomToolOverrideConfig, type ToolDisplayConfig } from "./types.js";
export const TOOL_DISPLAY_PRESETS = ["opencode", "balanced", "verbose"] as const;
export type ToolDisplayPreset = (typeof TOOL_DISPLAY_PRESETS)[number];
const TOOL_DISPLAY_PRESET_CONFIGS: Record<ToolDisplayPreset, ToolDisplayConfig> = {
opencode: {
...DEFAULT_TOOL_DISPLAY_CONFIG,
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
},
balanced: {
...DEFAULT_TOOL_DISPLAY_CONFIG,
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
readOutputMode: "summary",
searchOutputMode: "count",
mcpOutputMode: "summary",
bashOutputMode: "summary",
},
verbose: {
...DEFAULT_TOOL_DISPLAY_CONFIG,
registerToolOverrides: { ...DEFAULT_TOOL_DISPLAY_CONFIG.registerToolOverrides },
readOutputMode: "preview",
searchOutputMode: "preview",
mcpOutputMode: "preview",
bashOutputMode: "preview",
previewLines: 12,
bashCollapsedLines: 20,
},
};
function toolOverrideOwnershipEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
return (
a.registerToolOverrides.read === b.registerToolOverrides.read &&
a.registerToolOverrides.grep === b.registerToolOverrides.grep &&
a.registerToolOverrides.find === b.registerToolOverrides.find &&
a.registerToolOverrides.ls === b.registerToolOverrides.ls &&
a.registerToolOverrides.bash === b.registerToolOverrides.bash &&
a.registerToolOverrides.edit === b.registerToolOverrides.edit &&
a.registerToolOverrides.write === b.registerToolOverrides.write
);
}
function customToolOverridesEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
const aEntries = Object.entries(a.customToolOverrides).sort(([left], [right]) => left.localeCompare(right));
const bEntries = Object.entries(b.customToolOverrides).sort(([left], [right]) => left.localeCompare(right));
if (aEntries.length !== bEntries.length) {
return false;
}
return aEntries.every(([toolName, override], index) => {
const [otherToolName, otherOverride] = bEntries[index];
return (
toolName === otherToolName &&
override.enabled === otherOverride.enabled &&
override.kind === otherOverride.kind &&
override.outputMode === otherOverride.outputMode
);
});
}
function configsEqual(a: ToolDisplayConfig, b: ToolDisplayConfig): boolean {
return (
toolOverrideOwnershipEqual(a, b) &&
customToolOverridesEqual(a, b) &&
a.enableNativeUserMessageBox === b.enableNativeUserMessageBox &&
a.readOutputMode === b.readOutputMode &&
a.searchOutputMode === b.searchOutputMode &&
a.mcpOutputMode === b.mcpOutputMode &&
a.previewLines === b.previewLines &&
a.expandedPreviewMaxLines === b.expandedPreviewMaxLines &&
a.bashOutputMode === b.bashOutputMode &&
a.bashCollapsedLines === b.bashCollapsedLines &&
a.diffViewMode === b.diffViewMode &&
a.diffIndicatorMode === b.diffIndicatorMode &&
a.diffSplitMinWidth === b.diffSplitMinWidth &&
a.diffCollapsedLines === b.diffCollapsedLines &&
a.diffWordWrap === b.diffWordWrap &&
a.showTruncationHints === b.showTruncationHints &&
a.showRtkCompactionHints === b.showRtkCompactionHints
);
}
export function getToolDisplayPresetConfig(preset: ToolDisplayPreset): ToolDisplayConfig {
const config = TOOL_DISPLAY_PRESET_CONFIGS[preset];
return {
...config,
registerToolOverrides: { ...config.registerToolOverrides },
customToolOverrides: cloneCustomToolOverrides(config.customToolOverrides),
};
}
export function detectToolDisplayPreset(config: ToolDisplayConfig): ToolDisplayPreset | "custom" {
for (const preset of TOOL_DISPLAY_PRESETS) {
if (configsEqual(config, TOOL_DISPLAY_PRESET_CONFIGS[preset])) {
return preset;
}
}
return "custom";
}
export function parseToolDisplayPreset(raw: string): ToolDisplayPreset | undefined {
const normalized = raw.trim().toLowerCase();
if (!normalized) {
return undefined;
}
return TOOL_DISPLAY_PRESETS.find((preset) => preset === normalized);
}

View File

@@ -0,0 +1,186 @@
import { homedir } from "node:os";
import { sanitizeAnsiForThemedOutput } from "./ansi-utils.js";
export { sanitizeAnsiForThemedOutput };
interface TextLikeContent {
type: string;
text?: string;
}
interface ToolResultLike {
content?: unknown;
}
const QUIET_COMMAND_PREFIXES = [
"cd",
"mkdir",
"rmdir",
"rm",
"mv",
"cp",
"touch",
"chmod",
"chown",
"git add",
"git checkout",
"git switch",
"git restore",
"git reset",
"git clean",
"npm install",
"pnpm install",
"yarn install",
"bun install",
"pip install",
"poetry install",
"cargo fetch",
"go mod tidy",
"Set-Location",
"New-Item",
"Remove-Item",
"Move-Item",
"Copy-Item",
] as const;
interface CompactOutputOptions {
expanded: boolean;
maxCollapsedConsecutiveEmptyLines?: number;
}
function trimTrailingEmptyLines(lines: string[]): string[] {
const next = [...lines];
while (next.length > 0 && next[next.length - 1]?.trim().length === 0) {
next.pop();
}
return next;
}
function collapseConsecutiveEmptyLines(
lines: string[],
maxConsecutiveEmptyLines: number,
): string[] {
const maxAllowed = Math.max(0, maxConsecutiveEmptyLines);
if (maxAllowed === 0) {
return lines.filter((line) => line.trim().length > 0);
}
const compacted: string[] = [];
let consecutiveEmpty = 0;
for (const line of lines) {
if (line.trim().length === 0) {
consecutiveEmpty++;
if (consecutiveEmpty > maxAllowed) {
continue;
}
} else {
consecutiveEmpty = 0;
}
compacted.push(line);
}
return compacted;
}
export function shortenPath(inputPath: string | undefined): string {
if (!inputPath) {
return "";
}
const home = homedir();
return inputPath.startsWith(home)
? `~${inputPath.slice(home.length)}`
: inputPath;
}
export function extractTextOutput(result: ToolResultLike): string {
const rawBlocks = Array.isArray(result.content) ? result.content : [];
const blocks = rawBlocks.filter(
(block): block is TextLikeContent =>
typeof block === "object" &&
block !== null &&
"type" in block &&
(block as TextLikeContent).type === "text" &&
typeof (block as TextLikeContent).text === "string",
);
return blocks.map((block) => block.text ?? "").join("\n");
}
export function splitLines(text: string): string[] {
if (!text) {
return [];
}
return text
.replace(/\r/g, "")
.split("\n")
.map((line) => line.replace(/\t/g, " "));
}
export function compactOutputLines(
lines: string[],
options: CompactOutputOptions,
): string[] {
const trimmed = trimTrailingEmptyLines(lines);
if (options.expanded) {
return trimmed;
}
return collapseConsecutiveEmptyLines(
trimmed,
options.maxCollapsedConsecutiveEmptyLines ?? 1,
);
}
export function isLikelyQuietCommand(command: string | undefined): boolean {
if (!command) {
return false;
}
const normalized = command.trim().toLowerCase();
if (!normalized) {
return false;
}
const primarySegment = normalized
.split(/&&|\|\||;/)
.map((segment) => segment.trim())
.find((segment) => segment.length > 0);
if (!primarySegment) {
return false;
}
for (const prefix of QUIET_COMMAND_PREFIXES) {
const normalizedPrefix = prefix.toLowerCase();
if (
primarySegment === normalizedPrefix ||
primarySegment.startsWith(`${normalizedPrefix} `)
) {
return true;
}
}
return false;
}
export function countNonEmptyLines(lines: string[]): number {
return lines.filter((line) => line.trim().length > 0).length;
}
export function pluralize(
count: number,
singular: string,
plural = `${singular}s`,
): string {
return count === 1 ? singular : plural;
}
export function previewLines(
lines: string[],
maxLines: number,
): { shown: string[]; remaining: number } {
const limit = Math.max(0, maxLines);
const shown = lines.slice(0, limit);
const remaining = Math.max(0, lines.length - shown.length);
return { shown, remaining };
}

View File

@@ -0,0 +1,475 @@
import type { Theme } from "@earendil-works/pi-coding-agent";
import { Input, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { getModalIcons } from "./modal-icons.js";
import type { ZellijModalContentRenderer } from "./zellij-modal.js";
const SPLIT_PANE_MIN_WIDTH = 84;
const LIST_MIN_WIDTH = 28;
const INSPECTOR_MIN_WIDTH = 36;
const BODY_ROW_MIN = 8;
const BODY_ROW_MAX = 14;
const SEARCH_BOX_MIN_WIDTH = 12;
const SEARCH_BOX_MAX_WIDTH = 24;
const INSPECTOR_TITLE_GAP_ROWS = 1;
const INSPECTOR_PATH_GAP_ROWS = 1;
const FOOTER_ACTIONS = ["Space/Enter toggle", "↑↓ navigate", "Esc close"] as const;
export interface InspectorSettingItem {
id: string;
label: string;
currentValue: string;
values?: readonly string[];
inspectorTitle: string;
inspectorSummary: readonly string[];
inspectorOptions?: readonly string[];
inspectorPath?: string;
inspectorAdvanced?: readonly string[];
searchTerms?: readonly string[];
}
export interface SplitPaneInspectorModalOptions {
getSettings: () => readonly InspectorSettingItem[];
onChange: (id: string, value: string) => void;
onClose: () => void;
}
interface SplitPaneWidths {
list: number;
inspector: number;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function fitText(text: string, width: number): string {
const safeWidth = Math.max(0, width);
if (safeWidth === 0) {
return "";
}
const fitted = truncateToWidth(text, safeWidth, "…", true);
const padding = Math.max(0, safeWidth - visibleWidth(fitted));
return `${fitted}${" ".repeat(padding)}`;
}
function wrapText(text: string, width: number): string[] {
const safeWidth = Math.max(1, width);
const normalized = text.trim();
if (!normalized) {
return [];
}
const words = normalized.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (visibleWidth(candidate) <= safeWidth) {
current = candidate;
continue;
}
if (current) {
lines.push(current);
current = "";
}
if (visibleWidth(word) <= safeWidth) {
current = word;
continue;
}
let remaining = word;
while (visibleWidth(remaining) > safeWidth) {
const piece = truncateToWidth(remaining, safeWidth, "", false);
lines.push(piece);
remaining = remaining.slice(Math.max(1, piece.length));
}
current = remaining;
}
if (current) {
lines.push(current);
}
return lines.length > 0 ? lines : [normalized];
}
function getScrollableWindow<T>(items: readonly T[], selectedIndex: number, visibleRows: number): readonly T[] {
if (items.length <= visibleRows) {
return items;
}
const halfWindow = Math.floor(visibleRows / 2);
const maxStart = Math.max(0, items.length - visibleRows);
const start = clamp(selectedIndex - halfWindow, 0, maxStart);
return items.slice(start, start + visibleRows);
}
function splitPaneWidths(totalWidth: number): SplitPaneWidths {
const usable = Math.max(LIST_MIN_WIDTH + INSPECTOR_MIN_WIDTH, totalWidth - 1);
const preferredList = Math.floor(usable * 0.38);
const list = clamp(preferredList, LIST_MIN_WIDTH, Math.max(LIST_MIN_WIDTH, usable - INSPECTOR_MIN_WIDTH));
const inspector = Math.max(INSPECTOR_MIN_WIDTH, usable - list);
return { list, inspector };
}
function getBodyRowBudget(): number {
const terminalRows =
typeof process.stdout.rows === "number" && Number.isFinite(process.stdout.rows)
? process.stdout.rows
: 36;
return clamp(Math.floor(terminalRows * 0.33), BODY_ROW_MIN, BODY_ROW_MAX);
}
function isSlashInput(data: string): boolean {
return data === "/" || matchesKey(data, "/");
}
function shouldForwardInputToSearch(data: string): boolean {
if (!data) {
return false;
}
if (matchesKey(data, "space") || data === " ") {
return false;
}
return true;
}
export class SplitPaneInspectorModal implements ZellijModalContentRenderer {
private readonly options: SplitPaneInspectorModalOptions;
private readonly theme: Theme;
private readonly searchInput: Input;
private readonly icons = getModalIcons();
private selectedId: string | null = null;
private showAdvanced = false;
constructor(options: SplitPaneInspectorModalOptions, theme: Theme) {
this.options = options;
this.theme = theme;
this.searchInput = new Input();
this.searchInput.focused = true;
}
render(width: number): string[] {
const safeWidth = Math.max(1, width);
const items = this.getFilteredItems();
this.ensureSelection(items);
if (safeWidth < SPLIT_PANE_MIN_WIDTH) {
return this.renderStackedLayout(items, safeWidth);
}
return this.renderSplitLayout(items, safeWidth);
}
invalidate(): void {
// Fully state-driven renderer.
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
this.options.onClose();
return;
}
if (matchesKey(data, "up")) {
this.moveSelection(-1);
return;
}
if (matchesKey(data, "down")) {
this.moveSelection(1);
return;
}
if (matchesKey(data, "return") || matchesKey(data, "space") || data === " ") {
this.cycleSelectedValue();
return;
}
if (isSlashInput(data)) {
this.showAdvanced = !this.showAdvanced;
return;
}
if (!shouldForwardInputToSearch(data)) {
return;
}
this.searchInput.handleInput(data);
this.ensureSelection(this.getFilteredItems());
}
private renderSplitLayout(items: readonly InspectorSettingItem[], width: number): string[] {
const paneWidths = splitPaneWidths(width);
const bodyRows = getBodyRowBudget();
const header = this.buildHeaderRow(width);
const divider = this.buildHorizontalDivider(paneWidths.list, paneWidths.inspector);
const footer = this.buildFooterRow(width);
const listLines = this.buildListPaneLines(items, paneWidths.list, bodyRows);
const inspectorLines = this.buildInspectorPaneLines(this.getSelectedItem(items), paneWidths.inspector, bodyRows);
const lines: string[] = [header, divider];
const dividerPaint = this.theme.fg("dim", "│");
for (let index = 0; index < bodyRows; index += 1) {
const listLine = listLines[index] ?? " ".repeat(paneWidths.list);
const inspectorLine = inspectorLines[index] ?? " ".repeat(paneWidths.inspector);
lines.push(`${listLine}${dividerPaint}${inspectorLine}`);
}
lines.push(divider);
lines.push(footer);
return lines;
}
private renderStackedLayout(items: readonly InspectorSettingItem[], width: number): string[] {
const bodyRows = Math.max(BODY_ROW_MIN, Math.floor(getBodyRowBudget() / 2));
const divider = this.theme.fg("dim", "─".repeat(Math.max(1, width)));
return [
this.buildHeaderRow(width),
divider,
...this.buildListPaneLines(items, width, bodyRows),
divider,
...this.buildInspectorPaneLines(this.getSelectedItem(items), width, bodyRows + 2),
divider,
this.buildFooterRow(width),
];
}
private buildHeaderRow(width: number): string {
const searchBox = this.buildSearchBox(width);
const leftText = this.theme.fg("accent", this.theme.bold("Pi Tool Display Settings"));
const leftWidth = visibleWidth("Pi Tool Display Settings");
const searchWidth = visibleWidth(searchBox);
const gap = Math.max(1, width - leftWidth - searchWidth);
if (gap <= 1) {
return truncateToWidth(`${leftText} ${searchBox}`, width, "", true);
}
return `${leftText}${" ".repeat(gap)}${searchBox}`;
}
private buildSearchBox(width: number): string {
const desiredInnerWidth = clamp(Math.floor(width * 0.18), SEARCH_BOX_MIN_WIDTH, SEARCH_BOX_MAX_WIDTH);
const rendered = this.searchInput.render(desiredInnerWidth + 2)[0] ?? "> ";
const normalized = rendered.startsWith("> ") ? rendered.slice(2) : rendered;
const inner = fitText(normalized, desiredInnerWidth);
return `${this.theme.fg("muted", this.icons.search)} [${inner}]`;
}
private buildHorizontalDivider(leftWidth: number, rightWidth: number): string {
const paint = (text: string) => this.theme.fg("dim", text);
return `${paint("─".repeat(Math.max(1, leftWidth)))}${paint("┬")}${paint("─".repeat(Math.max(1, rightWidth)))}`;
}
private buildFooterRow(width: number): string {
const modeAction = this.showAdvanced ? "/ basic" : "/ advanced";
const text = [...FOOTER_ACTIONS.slice(0, 2), modeAction, FOOTER_ACTIONS[2]].join(" │ ");
return this.theme.fg("dim", fitText(text, width));
}
private getAllSettings(): readonly InspectorSettingItem[] {
return this.options.getSettings();
}
private getFilteredItems(): readonly InspectorSettingItem[] {
const query = this.searchInput.getValue().trim().toLowerCase();
const settings = this.getAllSettings();
if (!query) {
return settings;
}
return settings.filter((item) => {
const haystack = [
item.label,
item.currentValue,
item.inspectorTitle,
...item.inspectorSummary,
...(item.inspectorOptions ?? []),
...(item.searchTerms ?? []),
]
.join(" ")
.toLowerCase();
return haystack.includes(query);
});
}
private ensureSelection(items: readonly InspectorSettingItem[]): void {
if (items.length === 0) {
this.selectedId = null;
return;
}
if (this.selectedId && items.some((item) => item.id === this.selectedId)) {
return;
}
this.selectedId = items[0]?.id ?? null;
}
private moveSelection(delta: number): void {
const items = this.getFilteredItems();
if (items.length === 0) {
return;
}
this.ensureSelection(items);
const currentIndex = Math.max(0, items.findIndex((item) => item.id === this.selectedId));
const nextIndex = (currentIndex + delta + items.length) % items.length;
this.selectedId = items[nextIndex]?.id ?? this.selectedId;
}
private cycleSelectedValue(): void {
const item = this.getSelectedItem(this.getFilteredItems());
if (!item || !item.values || item.values.length === 0) {
return;
}
const currentIndex = item.values.indexOf(item.currentValue);
const nextIndex = (currentIndex + 1 + item.values.length) % item.values.length;
const nextValue = item.values[nextIndex] ?? item.values[0];
if (!nextValue) {
return;
}
this.options.onChange(item.id, nextValue);
this.selectedId = item.id;
}
private getSelectedItem(items: readonly InspectorSettingItem[]): InspectorSettingItem | null {
this.ensureSelection(items);
return items.find((item) => item.id === this.selectedId) ?? null;
}
private buildListPaneLines(items: readonly InspectorSettingItem[], width: number, rowCount: number): string[] {
const safeWidth = Math.max(1, width);
if (items.length === 0) {
return this.padRows(
[
this.theme.fg("warning", fitText("No matching settings.", safeWidth)),
this.theme.fg("dim", fitText("Backspace in search to widen the filter.", safeWidth)),
],
rowCount,
safeWidth,
);
}
const selectedIndex = Math.max(0, items.findIndex((item) => item.id === this.selectedId));
const visibleItems = getScrollableWindow(items, selectedIndex, rowCount);
const maxValueWidth = Math.max(...visibleItems.map((item) => visibleWidth(item.currentValue)), 6);
const valueWidth = clamp(maxValueWidth, 6, Math.max(6, Math.floor(safeWidth * 0.34)));
const labelWidth = Math.max(8, safeWidth - 3 - valueWidth);
const lines = visibleItems.map((item) => this.renderSettingRow(item, safeWidth, labelWidth, valueWidth));
return this.padRows(lines, rowCount, safeWidth);
}
private renderSettingRow(item: InspectorSettingItem, width: number, labelWidth: number, valueWidth: number): string {
const selected = item.id === this.selectedId;
const cursor = selected ? this.theme.fg("accent", this.theme.bold(">")) : " ";
const labelText = fitText(item.label, labelWidth);
const valueText = fitText(item.currentValue, valueWidth);
const styledLabel = selected ? this.theme.bold(labelText) : labelText;
const styledValue = selected ? this.theme.fg("accent", valueText) : this.theme.fg("muted", valueText);
return truncateToWidth(`${cursor} ${styledLabel} ${styledValue}`, width, "", true);
}
private buildInspectorPaneLines(selectedItem: InspectorSettingItem | null, width: number, rowCount: number): string[] {
const safeWidth = Math.max(1, width);
if (!selectedItem) {
return this.padRows(
[
this.theme.fg("accent", fitText("[ Search ]", safeWidth)),
"",
...this.colorWrappedParagraphs(
["No settings matched the current filter. Adjust the search field to repopulate the settings index."],
safeWidth,
"muted",
),
],
rowCount,
safeWidth,
);
}
const topLines: string[] = [this.theme.fg("accent", fitText(`[ ${selectedItem.inspectorTitle} ]`, safeWidth))];
for (let index = 0; index < INSPECTOR_TITLE_GAP_ROWS; index += 1) {
topLines.push("");
}
topLines.push(...this.colorWrappedParagraphs(selectedItem.inspectorSummary, safeWidth, "muted"));
if (this.showAdvanced && (selectedItem.inspectorAdvanced?.length ?? 0) > 0) {
topLines.push("");
topLines.push(this.theme.fg("accent", fitText("Advanced:", safeWidth)));
topLines.push(...this.colorWrappedBullets(selectedItem.inspectorAdvanced ?? [], safeWidth, "dim"));
}
if ((selectedItem.inspectorOptions?.length ?? 0) > 0) {
topLines.push("");
topLines.push(this.theme.fg("dim", fitText("Options:", safeWidth)));
topLines.push(...this.colorWrappedBullets(selectedItem.inspectorOptions ?? [], safeWidth, "muted"));
}
const bottomLines: string[] = [];
if (selectedItem.inspectorPath) {
for (let index = 0; index < INSPECTOR_PATH_GAP_ROWS; index += 1) {
bottomLines.push(" ".repeat(safeWidth));
}
bottomLines.push(this.theme.fg("dim", fitText("Path:", safeWidth)));
bottomLines.push(this.theme.fg("muted", fitText(selectedItem.inspectorPath, safeWidth)));
}
return this.composeInspectorRows(topLines, bottomLines, rowCount, safeWidth);
}
private composeInspectorRows(topLines: string[], bottomLines: string[], rowCount: number, width: number): string[] {
const totalLines = [...topLines];
if (bottomLines.length === 0) {
return this.padRows(totalLines, rowCount, width);
}
if (topLines.length + bottomLines.length <= rowCount) {
const spacerCount = rowCount - topLines.length - bottomLines.length;
return [...topLines, ...Array.from({ length: spacerCount }, () => " ".repeat(width)), ...bottomLines];
}
const reservedBottom = bottomLines.length;
const maxTopLines = Math.max(1, rowCount - reservedBottom - 1);
const trimmedTop = topLines.slice(0, maxTopLines);
trimmedTop.push(this.theme.fg("dim", fitText("…", width)));
return [...trimmedTop, ...bottomLines].slice(0, rowCount);
}
private colorWrappedParagraphs(paragraphs: readonly string[], width: number, color: "muted" | "dim"): string[] {
const lines: string[] = [];
for (const paragraph of paragraphs) {
for (const line of wrapText(paragraph, width)) {
lines.push(this.theme.fg(color, fitText(line, width)));
}
}
return lines;
}
private colorWrappedBullets(bullets: readonly string[], width: number, color: "muted" | "dim"): string[] {
const lines: string[] = [];
const bulletPrefix = "• ";
const continuationPrefix = " ";
const contentWidth = Math.max(1, width - visibleWidth(bulletPrefix));
for (const bullet of bullets) {
const wrapped = wrapText(bullet, contentWidth);
for (const [index, line] of wrapped.entries()) {
const prefix = index === 0 ? bulletPrefix : continuationPrefix;
lines.push(this.theme.fg(color, fitText(`${prefix}${line}`, width)));
}
}
return lines;
}
private padRows(lines: string[], rowCount: number, width: number): string[] {
const padded = [...lines];
while (padded.length < rowCount) {
padded.push(" ".repeat(width));
}
return padded.slice(0, rowCount);
}
}

View File

@@ -0,0 +1,343 @@
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { isRecord } from "./tool-metadata.js";
import { onReloadShutdown } from "./extension-lifecycle.js";
interface ThemeLike {
fg(color: string, text: string): string;
}
interface AssistantMessageLike {
role?: unknown;
api?: unknown;
content?: unknown;
}
const THINKING_CHAT_PREFIX = "Thinking: ";
const THINKING_LABEL_PREFIX_PATTERN = /^(?:thinking:\s*)+/i;
const LEADING_ANSI_FRAGMENT_PATTERN = /^(?:\s*;?\d{1,3}(?:;\d{1,3})*m)+\s*/;
const MAX_THINKING_CONTENT_DEPTH = 16;
const registeredThinkingApis = new WeakSet<ExtensionAPI>();
const OPENAI_REASONING_APIS = new Set([
"openai-completions",
"openai-responses",
"openai-codex-responses",
]);
const ANTHROPIC_REASONING_APIS = new Set([
"anthropic-messages",
"anthropic-responses",
"anthropic-completions",
]);
function normalizeApiName(api: unknown): string | undefined {
if (typeof api !== "string") {
return undefined;
}
const normalized = api.trim().toLowerCase();
return normalized.length > 0 ? normalized : undefined;
}
function shouldPrefixThinkingForApi(api: unknown): boolean {
const normalizedApi = normalizeApiName(api);
if (!normalizedApi) {
return true;
}
if (OPENAI_REASONING_APIS.has(normalizedApi)) {
return true;
}
if (ANTHROPIC_REASONING_APIS.has(normalizedApi)) {
return true;
}
if (normalizedApi.startsWith("anthropic-")) {
return true;
}
// Keep OpenAI handling explicit to avoid applying this formatter to
// unrelated OpenAI transport APIs that may not emit thinking blocks.
if (normalizedApi.startsWith("openai-")) {
return false;
}
// For non-OpenAI providers, apply the prefix when thinking blocks exist.
return true;
}
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "");
}
function stripLeadingAnsiFragments(text: string): string {
let current = text;
while (true) {
const next = current.replace(LEADING_ANSI_FRAGMENT_PATTERN, "");
if (next === current) {
return current;
}
current = next;
}
}
function stripThinkingPresentationArtifacts(text: string): string {
let current = stripAnsi(text);
let removedThinkingLabel = false;
while (true) {
const withoutLabel = current
.replace(THINKING_LABEL_PREFIX_PATTERN, "")
.trimStart();
if (withoutLabel !== current) {
current = withoutLabel;
removedThinkingLabel = true;
continue;
}
const withoutAnsiFragments = stripLeadingAnsiFragments(current).trimStart();
if (withoutAnsiFragments !== current) {
const fragmentExposedAnotherLabel =
withoutAnsiFragments.replace(THINKING_LABEL_PREFIX_PATTERN, "").trimStart() !==
withoutAnsiFragments;
if (removedThinkingLabel || fragmentExposedAnotherLabel) {
current = withoutAnsiFragments;
continue;
}
}
return current;
}
}
function formatThinkingLabel(theme: ThemeLike | undefined, thinkingText: string): string {
if (!theme) {
return `${THINKING_CHAT_PREFIX}${thinkingText}`;
}
const label = theme.fg("accent", THINKING_CHAT_PREFIX.trimEnd());
const body = theme.fg("thinkingText", thinkingText);
return `${label} ${body}`;
}
function prefixThinkingLine(text: string, theme: ThemeLike | undefined): string {
const normalizedThinking = stripThinkingPresentationArtifacts(text).trim();
if (!normalizedThinking) {
return text;
}
return formatThinkingLabel(theme, normalizedThinking);
}
function normalizeThinkingLineForContext(text: string): string {
return stripThinkingPresentationArtifacts(text);
}
function isThinkingBlock(value: unknown): value is Record<string, unknown> & {
type: "thinking";
thinking: string;
} {
if (!isRecord(value)) {
return false;
}
return value.type === "thinking" && typeof value.thinking === "string";
}
function mapThinkingContentArray(
content: unknown[],
mapThinkingText: (text: string) => string,
depth = 0,
seen: WeakSet<object> = new WeakSet<object>(),
): { content: unknown[]; changed: boolean } {
if (depth > MAX_THINKING_CONTENT_DEPTH || seen.has(content)) {
return { content, changed: false };
}
seen.add(content);
let changed = false;
const nextContent = content.map((block) => {
if (Array.isArray(block)) {
const nested = mapThinkingContentArray(
block,
mapThinkingText,
depth + 1,
seen,
);
if (nested.changed) {
changed = true;
return nested.content;
}
return block as unknown[];
}
if (!isThinkingBlock(block)) {
return block;
}
const nextThinking = mapThinkingText(block.thinking);
if (nextThinking === block.thinking) {
return block;
}
changed = true;
return { ...block, thinking: nextThinking };
});
return { content: changed ? nextContent : content, changed };
}
function withThinkingLabelsForDisplay(
content: unknown,
theme: ThemeLike | undefined,
): unknown {
if (!Array.isArray(content)) {
return content;
}
const mapped = mapThinkingContentArray(content, (thinking) =>
prefixThinkingLine(thinking, theme),
);
return mapped.changed ? mapped.content : content;
}
function sanitizeThinkingBlocksForContext(message: AssistantMessageLike): AssistantMessageLike {
if (!Array.isArray(message.content)) {
return message;
}
const mapped = mapThinkingContentArray(
message.content,
normalizeThinkingLineForContext,
);
return mapped.changed ? { ...message, content: mapped.content } : message;
}
function sanitizeContextMessages(messages: unknown): unknown {
if (!Array.isArray(messages)) {
return messages;
}
const messageList = messages as unknown[];
let changed = false;
const nextMessages = messageList.map((message) => {
if (!isRecord(message) || message.role !== "assistant") {
return message;
}
const sanitized = sanitizeThinkingBlocksForContext(message as AssistantMessageLike);
if (sanitized !== message) {
changed = true;
return sanitized;
}
return message;
});
return changed ? nextMessages : messageList;
}
function prefixThinkingBlocksForDisplay(
message: AssistantMessageLike,
theme: ThemeLike | undefined,
): void {
if (!shouldPrefixThinkingForApi(message.api)) {
return;
}
const displayContent = withThinkingLabelsForDisplay(message.content, theme);
if (displayContent !== message.content) {
message.content = displayContent;
}
}
function extractAssistantMessage(event: unknown): AssistantMessageLike | undefined {
if (!isRecord(event)) {
return undefined;
}
const maybeMessage = event.message;
if (!isRecord(maybeMessage)) {
return undefined;
}
if (maybeMessage.role !== "assistant") {
return undefined;
}
return maybeMessage as AssistantMessageLike;
}
function processThinkingEvent(
event: unknown,
ctx: ExtensionContext | undefined,
notifyPrefix: string,
): void {
try {
const message = extractAssistantMessage(event);
if (!message) {
return;
}
prefixThinkingBlocksForDisplay(message, ctx?.ui?.theme);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
ctx?.ui?.notify(`${notifyPrefix}: ${errorMessage}`, "warning");
}
}
function handleThinkingMessageUpdateEvent(event: unknown, ctx: ExtensionContext | undefined): void {
// Render-only labeling: update the transient message_update payload while
// leaving canonical session/LLM context content untouched.
processThinkingEvent(event, ctx, "Thinking label formatting failed");
}
function handleThinkingMessageEndEvent(event: unknown, ctx: ExtensionContext | undefined): void {
// Persist themed labels on final assistant messages so the label remains
// visible after streaming ends and across session reloads.
// Context sanitization strips these presentation artifacts before each LLM call.
processThinkingEvent(event, ctx, "Thinking label finalization failed");
}
function handleThinkingContextEvent(event: unknown, ctx: ExtensionContext | undefined): void {
try {
if (!isRecord(event) || !Array.isArray(event.messages)) {
return;
}
const sanitizedMessages = sanitizeContextMessages(event.messages);
if (sanitizedMessages !== event.messages && Array.isArray(sanitizedMessages)) {
event.messages.splice(0, event.messages.length, ...(sanitizedMessages as unknown[]));
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
ctx?.ui?.notify(`Thinking context sanitization failed: ${message}`, "warning");
}
}
export function registerThinkingLabeling(pi: ExtensionAPI): void {
if (registeredThinkingApis.has(pi)) {
return;
}
registeredThinkingApis.add(pi);
onReloadShutdown(pi, () => {
registeredThinkingApis.delete(pi);
});
pi.on("message_update", async (event, ctx) => {
handleThinkingMessageUpdateEvent(event, ctx);
});
pi.on("message_end", async (event, ctx) => {
handleThinkingMessageEndEvent(event, ctx);
});
pi.on("context", async (event, ctx) => {
handleThinkingContextEvent(event, ctx);
});
}

View File

@@ -0,0 +1,139 @@
export interface PromptMetadata {
promptSnippet?: string;
promptGuidelines?: string[];
}
const MCP_DESCRIPTION_PATTERN = /\bmcp\b/i;
const MCP_ADAPTER_SOURCE_PATTERN = /(?:^|[/\\@_-])(?:pi-)?mcp(?:[/\\@_-]|$)|pi-mcp-adapter|mcp-adapter/i;
const MAX_PROMPT_SNIPPET_LENGTH = 120;
export const MCP_PROXY_PROMPT_SNIPPET = "Discover, inspect, and call MCP tools across configured servers";
export const MCP_PROXY_PROMPT_GUIDELINES = [
"Use mcp for MCP discovery first: search by capability, describe one exact tool, then call it.",
] as const;
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function toRecord(value: unknown): Record<string, unknown> {
if (!isRecord(value)) {
return {};
}
return value;
}
export function getTextField(value: unknown, field: string): string | undefined {
const record = toRecord(value);
const raw = record[field];
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
}
function normalizeInlineText(value: string): string {
return value.trim().replace(/\s+/g, " ");
}
function trimPromptSnippet(value: string): string {
if (value.length <= MAX_PROMPT_SNIPPET_LENGTH) {
return value;
}
const truncated = value.slice(0, MAX_PROMPT_SNIPPET_LENGTH).trimEnd();
return `${truncated.replace(/[\s.,;:!?-]+$/u, "")}`;
}
export function buildPromptSnippetFromDescription(description: string | undefined, fallback: string): string {
const normalizedDescription = normalizeInlineText(description || "");
const normalizedFallback = normalizeInlineText(fallback);
const base = normalizedDescription || normalizedFallback;
const firstSentence = base.split(/(?<=[.!?])\s+/u, 1)[0] ?? base;
const withoutSentencePunctuation = firstSentence.replace(/[.!?]+$/u, "").trim();
return trimPromptSnippet(withoutSentencePunctuation || base);
}
export function extractPromptMetadata(tool: unknown): PromptMetadata {
const source = toRecord(tool);
const promptSnippet =
typeof source.promptSnippet === "string" && source.promptSnippet.trim().length > 0
? source.promptSnippet
: undefined;
const promptGuidelines = Array.isArray(source.promptGuidelines)
? source.promptGuidelines.filter(
(guideline): guideline is string =>
typeof guideline === "string" && guideline.trim().length > 0,
)
: undefined;
return {
promptSnippet,
promptGuidelines:
promptGuidelines && promptGuidelines.length > 0
? [...promptGuidelines]
: undefined,
};
}
function hasMcpSourceInfo(value: unknown): boolean {
const sourceInfo = toRecord(value);
for (const [key, raw] of Object.entries(sourceInfo)) {
if (typeof raw !== "string" || raw.trim().length === 0) {
continue;
}
const normalizedKey = key.toLowerCase();
const normalizedValue = raw.trim();
if (["source", "type", "kind", "origin"].includes(normalizedKey) && normalizedValue.toLowerCase() === "mcp") {
return true;
}
if (MCP_ADAPTER_SOURCE_PATTERN.test(normalizedValue)) {
return true;
}
}
return false;
}
export function isMcpToolCandidate(tool: unknown): boolean {
if (!tool || typeof tool !== "object") {
return false;
}
const record = tool as Record<string, unknown>;
const name = typeof record.name === "string" ? record.name : "";
const description = typeof record.description === "string" ? record.description : "";
const label = typeof record.label === "string" ? record.label : "";
if (name === "mcp") {
return true;
}
if (MCP_DESCRIPTION_PATTERN.test(description) || MCP_DESCRIPTION_PATTERN.test(label)) {
return true;
}
if (hasMcpSourceInfo(record.sourceInfo)) {
return true;
}
if (/^mcp[_-]/i.test(name) || /_mcp$/i.test(name)) {
return true;
}
if (name.includes(":")) {
return true;
}
if (/^ctx_/i.test(name)) {
return true;
}
const params = record.parameters;
if (params && typeof params === "object") {
const parameterRecord = params as Record<string, unknown>;
if (
"mcpServer" in parameterRecord ||
"serverUrl" in parameterRecord ||
"server_name" in parameterRecord
) {
return true;
}
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,105 @@
export const READ_OUTPUT_MODES = ["hidden", "summary", "preview"] as const;
export const SEARCH_OUTPUT_MODES = ["hidden", "count", "preview"] as const;
export const MCP_OUTPUT_MODES = ["hidden", "summary", "preview"] as const;
export const CUSTOM_TOOL_OVERRIDE_KINDS = ["generic", "mcp"] as const;
export const CUSTOM_TOOL_OUTPUT_MODES = ["hidden", "summary", "preview"] as const;
export const BASH_OUTPUT_MODES = ["opencode", "summary", "preview"] as const;
export const DIFF_VIEW_MODES = ["auto", "split", "unified"] as const;
export const DIFF_INDICATOR_MODES = ["bars", "classic", "none"] as const;
export type ReadOutputMode = (typeof READ_OUTPUT_MODES)[number];
export type SearchOutputMode = (typeof SEARCH_OUTPUT_MODES)[number];
export type McpOutputMode = (typeof MCP_OUTPUT_MODES)[number];
export type CustomToolOverrideKind = (typeof CUSTOM_TOOL_OVERRIDE_KINDS)[number];
export type CustomToolOutputMode = (typeof CUSTOM_TOOL_OUTPUT_MODES)[number];
export type BashOutputMode = (typeof BASH_OUTPUT_MODES)[number];
export type DiffViewMode = (typeof DIFF_VIEW_MODES)[number];
export type DiffIndicatorMode = (typeof DIFF_INDICATOR_MODES)[number];
export const BUILT_IN_TOOL_OVERRIDE_NAMES = [
"read",
"grep",
"find",
"ls",
"bash",
"edit",
"write",
] as const;
export type BuiltInToolOverrideName = (typeof BUILT_IN_TOOL_OVERRIDE_NAMES)[number];
export interface ToolOverrideOwnership {
read: boolean;
grep: boolean;
find: boolean;
ls: boolean;
bash: boolean;
edit: boolean;
write: boolean;
}
export interface CustomToolOverrideConfig {
enabled: boolean;
kind: CustomToolOverrideKind;
outputMode: CustomToolOutputMode;
}
export interface ToolDisplayConfig {
enabled: boolean;
registerToolOverrides: ToolOverrideOwnership;
customToolOverrides: Record<string, CustomToolOverrideConfig>;
enableNativeUserMessageBox: boolean;
readOutputMode: ReadOutputMode;
searchOutputMode: SearchOutputMode;
mcpOutputMode: McpOutputMode;
previewLines: number;
expandedPreviewMaxLines: number;
bashOutputMode: BashOutputMode;
bashCollapsedLines: number;
diffViewMode: DiffViewMode;
diffIndicatorMode: DiffIndicatorMode;
diffSplitMinWidth: number;
diffCollapsedLines: number;
diffWordWrap: boolean;
showTruncationHints: boolean;
showRtkCompactionHints: boolean;
}
export const DEFAULT_TOOL_DISPLAY_CONFIG: ToolDisplayConfig = {
enabled: true,
registerToolOverrides: {
read: true,
grep: true,
find: true,
ls: true,
bash: true,
edit: true,
write: true,
},
customToolOverrides: {},
enableNativeUserMessageBox: true,
readOutputMode: "hidden",
searchOutputMode: "hidden",
mcpOutputMode: "hidden",
previewLines: 8,
expandedPreviewMaxLines: 4000,
bashOutputMode: "opencode",
bashCollapsedLines: 10,
diffViewMode: "auto",
diffIndicatorMode: "bars",
diffSplitMinWidth: 120,
diffCollapsedLines: 24,
diffWordWrap: true,
showTruncationHints: false,
showRtkCompactionHints: false,
};
export interface ConfigLoadResult {
config: ToolDisplayConfig;
error?: string;
}
export interface ConfigSaveResult {
success: boolean;
error?: string;
}

View File

@@ -0,0 +1,64 @@
import { isRecord } from "./tool-metadata.js";
interface MarkdownLike {
text?: unknown;
theme?: unknown;
defaultTextStyle?: unknown;
}
interface UserMessageLike {
children?: unknown;
}
export interface UserMessageMarkdownState {
text: string;
theme: unknown;
defaultTextStyle?: Record<string, unknown>;
}
function sanitizeDefaultTextStyle(value: unknown): Record<string, unknown> | undefined {
if (!isRecord(value)) {
return undefined;
}
const { bgColor: _bgColor, ...rest } = value;
return Object.keys(rest).length > 0 ? rest : undefined;
}
function isMarkdownLike(value: unknown): value is MarkdownLike {
return isRecord(value) && typeof value.text === "string" && value.theme !== undefined;
}
function findMarkdownChild(value: unknown): MarkdownLike | undefined {
if (isMarkdownLike(value)) {
return value;
}
if (!isRecord(value)) {
return undefined;
}
const children = Array.isArray(value.children) ? value.children : [];
for (const child of children) {
const markdownChild = findMarkdownChild(child);
if (markdownChild) {
return markdownChild;
}
}
return undefined;
}
export function extractUserMessageMarkdownState(
userMessage: UserMessageLike,
): UserMessageMarkdownState | undefined {
const markdownChild = findMarkdownChild(userMessage);
if (!markdownChild || typeof markdownChild.text !== "string") {
return undefined;
}
return {
text: markdownChild.text,
theme: markdownChild.theme,
defaultTextStyle: sanitizeDefaultTextStyle(markdownChild.defaultTextStyle),
};
}

View File

@@ -0,0 +1,66 @@
import {
type ExtensionAPI,
UserMessageComponent,
} from "@earendil-works/pi-coding-agent";
import {
patchNativeUserMessagePrototype,
type PatchableUserMessagePrototype,
type UserMessageTheme,
} from "./user-message-box-renderer.js";
import { unregisterUserMessageRenderPrototypePatch } from "./user-message-box-patch.js";
import type { ToolDisplayConfig } from "./types.js";
import { onReloadShutdown } from "./extension-lifecycle.js";
const registeredNativeUserMessageApis = new WeakSet<ExtensionAPI>();
function getUserMessagePrototype(): PatchableUserMessagePrototype {
return UserMessageComponent.prototype as unknown as PatchableUserMessagePrototype;
}
function patchUserMessageRender(
getTheme: () => UserMessageTheme | undefined,
isEnabled: () => boolean,
): void {
patchNativeUserMessagePrototype(
getUserMessagePrototype(),
getTheme,
isEnabled,
);
}
function restoreUserMessageRender(): void {
unregisterUserMessageRenderPrototypePatch(getUserMessagePrototype());
}
export default function registerNativeUserMessageBox(
pi: ExtensionAPI,
getConfig: () => ToolDisplayConfig,
): void {
if (registeredNativeUserMessageApis.has(pi)) {
return;
}
registeredNativeUserMessageApis.add(pi);
let activeTheme: UserMessageTheme | undefined;
const getTheme = (): UserMessageTheme | undefined => activeTheme;
const isEnabled = (): boolean => getConfig().enableNativeUserMessageBox;
patchUserMessageRender(getTheme, isEnabled);
onReloadShutdown(pi, () => {
restoreUserMessageRender();
activeTheme = undefined;
registeredNativeUserMessageApis.delete(pi);
});
pi.on("before_agent_start", async () => {
patchUserMessageRender(getTheme, isEnabled);
});
pi.on("session_start", async (_event, ctx) => {
activeTheme = ctx?.ui?.theme as unknown as UserMessageTheme;
patchUserMessageRender(getTheme, isEnabled);
});
}

View File

@@ -0,0 +1,72 @@
export type UserMessageRenderFn = (width: number) => string[];
const USER_MESSAGE_PATCH_OWNER = {};
export interface PatchableUserMessagePrototype {
render: UserMessageRenderFn;
__piUserMessageOriginalRender?: UserMessageRenderFn;
__piUserMessageNativePatched?: boolean;
__piUserMessagePatchVersion?: number;
__piUserMessagePatchOwner?: object;
}
export function unregisterUserMessageRenderPrototypePatch(
prototype: PatchableUserMessagePrototype,
): void {
const originalRender = prototype.__piUserMessageOriginalRender;
if (typeof originalRender === "function") {
prototype.render = originalRender;
}
delete prototype.__piUserMessageOriginalRender;
delete prototype.__piUserMessageNativePatched;
delete prototype.__piUserMessagePatchVersion;
delete prototype.__piUserMessagePatchOwner;
}
export function patchUserMessageRenderPrototype(
prototype: PatchableUserMessagePrototype,
patchVersion: number,
buildRender: (originalRender: UserMessageRenderFn) => UserMessageRenderFn,
): void {
if (typeof prototype.render !== "function") {
return;
}
const previousOriginalRender = prototype.__piUserMessageOriginalRender;
const hasPreviousPatch = typeof previousOriginalRender === "function"
&& previousOriginalRender !== prototype.render;
const isCurrentPatch = prototype.__piUserMessagePatchOwner === USER_MESSAGE_PATCH_OWNER;
let restoredStalePatch = false;
if (hasPreviousPatch && !isCurrentPatch) {
prototype.render = previousOriginalRender;
delete prototype.__piUserMessageNativePatched;
delete prototype.__piUserMessagePatchVersion;
delete prototype.__piUserMessagePatchOwner;
restoredStalePatch = true;
}
if (
!restoredStalePatch
&& prototype.__piUserMessageNativePatched
&& prototype.__piUserMessagePatchVersion === patchVersion
&& typeof prototype.__piUserMessageOriginalRender === "function"
) {
return;
}
if (!prototype.__piUserMessageOriginalRender) {
prototype.__piUserMessageOriginalRender = prototype.render;
}
const originalRender = prototype.__piUserMessageOriginalRender;
if (!originalRender) {
return;
}
prototype.render = buildRender(originalRender);
prototype.__piUserMessageNativePatched = true;
prototype.__piUserMessagePatchVersion = patchVersion;
prototype.__piUserMessagePatchOwner = USER_MESSAGE_PATCH_OWNER;
}

View File

@@ -0,0 +1,419 @@
import {
Markdown,
truncateToWidth,
visibleWidth,
type DefaultTextStyle,
type MarkdownTheme,
} from "@earendil-works/pi-tui";
import {
patchUserMessageRenderPrototype,
type PatchableUserMessagePrototype,
} from "./user-message-box-patch.js";
import {
extractUserMessageMarkdownState,
type UserMessageMarkdownState,
} from "./user-message-box-markdown.js";
export type { PatchableUserMessagePrototype } from "./user-message-box-patch.js";
import {
addUserMessageVerticalPadding,
applyUserMessageBackground,
normalizeUserMessageContentLine,
normalizeUserMessageContentLines,
type UserMessageBackgroundTheme,
} from "./user-message-box-utils.js";
export interface UserMessageTheme extends UserMessageBackgroundTheme {
fg(color: string, text: string): string;
bold?(text: string): string;
}
interface CachedUserMessageMarkdownRenderer {
text: string;
theme: unknown;
defaultTextStyle?: Record<string, unknown>;
renderer: { render(width: number): string[] };
renderedWidth: number;
renderedLines: string[];
}
interface CachedUserMessageFinalOutput {
width: number;
theme: UserMessageTheme | undefined;
hasMarkdownState: boolean;
text?: string;
markdownTheme?: unknown;
defaultTextStyle?: Record<string, unknown>;
output: string[];
}
interface CachedUserMessageBodyLines {
width: number;
lines: string[];
}
const MIN_BORDER_WIDTH = 8;
const TITLE_TEXT = " user ";
const CONTENT_HORIZONTAL_PADDING_COLUMNS = 1;
const USER_MESSAGE_TOP_MARGIN_LINES = 1;
const USER_MESSAGE_PATCH_VERSION = 8;
const MAX_USER_MESSAGE_MARKDOWN_TEXT_LENGTH = 100_000;
const MAX_USER_MESSAGE_MARKDOWN_LINE_COUNT = 2_000;
function colorBorder(theme: UserMessageTheme | undefined, text: string): string {
if (!text || !theme) {
return text;
}
try {
return theme.fg("border", text);
} catch {
return text;
}
}
function colorTitle(theme: UserMessageTheme | undefined, title: string): string {
if (!title) {
return title;
}
const base = theme?.bold ? theme.bold(title) : title;
if (!theme) {
return base;
}
try {
return theme.fg("accent", base);
} catch {
return base;
}
}
function colorUserBackground(
theme: UserMessageTheme | undefined,
text: string,
): string {
return applyUserMessageBackground(theme, text);
}
function computeBoxInnerWidth(totalWidth: number): number {
return Math.max(0, totalWidth - 2);
}
function buildTopBorder(
totalWidth: number,
theme: UserMessageTheme | undefined,
): string {
const innerWidth = computeBoxInnerWidth(totalWidth);
const title = truncateToWidth(TITLE_TEXT, innerWidth, "");
const fill = "─".repeat(Math.max(0, innerWidth - visibleWidth(title)));
const row = `${colorBorder(theme, "╭")}${colorTitle(theme, title)}${colorBorder(theme, `${fill}`)}`;
return colorUserBackground(theme, row);
}
function buildBottomBorder(
totalWidth: number,
theme: UserMessageTheme | undefined,
): string {
const innerWidth = computeBoxInnerWidth(totalWidth);
const row = `${colorBorder(theme, "╰")}${colorBorder(theme, `${"─".repeat(innerWidth)}`)}`;
return colorUserBackground(theme, row);
}
function getUserMessageContentWidth(totalWidth: number): number {
return Math.max(
1,
totalWidth - 2 - CONTENT_HORIZONTAL_PADDING_COLUMNS * 2,
);
}
function wrapContentLine(
line: string,
totalWidth: number,
theme: UserMessageTheme | undefined,
): string {
const sidePadding = " ".repeat(CONTENT_HORIZONTAL_PADDING_COLUMNS);
const innerWidth = getUserMessageContentWidth(totalWidth);
const normalizedLine = normalizeUserMessageContentLine(line);
const content = truncateToWidth(normalizedLine, innerWidth, "", true);
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
const row = `${colorBorder(theme, "│")}${sidePadding}${content}${padding}${sidePadding}${colorBorder(theme, "│")}`;
return colorUserBackground(theme, row);
}
function createMarkdownRenderer(
markdownState: UserMessageMarkdownState,
): { render(width: number): string[] } {
return new Markdown(
markdownState.text,
0,
0,
markdownState.theme as MarkdownTheme,
markdownState.defaultTextStyle as DefaultTextStyle | undefined,
);
}
function countUserMessageLines(text: string, maxLines: number): number {
let lineCount = 1;
for (const character of text) {
if (character !== "\n") {
continue;
}
lineCount++;
if (lineCount > maxLines) {
return lineCount;
}
}
return lineCount;
}
function hasSameDefaultTextStyle(
left: Record<string, unknown> | undefined,
right: Record<string, unknown> | undefined,
): boolean {
if (left === right) {
return true;
}
if (!left || !right) {
return left === right;
}
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if (leftKeys.length !== rightKeys.length) {
return false;
}
for (const key of leftKeys) {
if (left[key] !== right[key]) {
return false;
}
}
return true;
}
function hasSameMarkdownState(
cached: Pick<CachedUserMessageMarkdownRenderer, "text" | "theme" | "defaultTextStyle">,
state: UserMessageMarkdownState,
): boolean {
return cached.text === state.text
&& cached.theme === state.theme
&& hasSameDefaultTextStyle(cached.defaultTextStyle, state.defaultTextStyle);
}
function hasSameFinalOutputState(
cached: CachedUserMessageFinalOutput,
width: number,
theme: UserMessageTheme | undefined,
markdownState: UserMessageMarkdownState | undefined,
): boolean {
if (cached.width !== width || cached.theme !== theme) {
return false;
}
if (!markdownState) {
return !cached.hasMarkdownState;
}
return cached.hasMarkdownState
&& hasSameMarkdownState(
{
text: cached.text ?? "",
theme: cached.markdownTheme,
defaultTextStyle: cached.defaultTextStyle,
},
markdownState,
);
}
function toFinalOutputCacheEntry(
width: number,
theme: UserMessageTheme | undefined,
markdownState: UserMessageMarkdownState | undefined,
output: string[],
): CachedUserMessageFinalOutput {
if (!markdownState) {
return {
width,
theme,
hasMarkdownState: false,
output,
};
}
return {
width,
theme,
hasMarkdownState: true,
text: markdownState.text,
markdownTheme: markdownState.theme,
defaultTextStyle: markdownState.defaultTextStyle,
output,
};
}
export function shouldBypassUserMessageMarkdownRebuild(
markdownState: UserMessageMarkdownState,
): boolean {
if (markdownState.text.length > MAX_USER_MESSAGE_MARKDOWN_TEXT_LENGTH) {
return true;
}
return countUserMessageLines(
markdownState.text,
MAX_USER_MESSAGE_MARKDOWN_LINE_COUNT,
) > MAX_USER_MESSAGE_MARKDOWN_LINE_COUNT;
}
export function createUserMessageMarkdownLineRenderer(
buildRenderer: (
markdownState: UserMessageMarkdownState,
) => { render(width: number): string[] } = createMarkdownRenderer,
): (
instance: object,
markdownState: UserMessageMarkdownState,
width: number,
) => string[] {
const cache = new WeakMap<object, CachedUserMessageMarkdownRenderer>();
return (instance, markdownState, width) => {
const cached = cache.get(instance);
const canReuseRenderer = cached
? hasSameMarkdownState(cached, markdownState)
: false;
if (canReuseRenderer && cached?.renderedWidth === width) {
return cached.renderedLines;
}
const renderer = canReuseRenderer && cached
? cached.renderer
: buildRenderer(markdownState);
const renderedLines = renderer.render(width);
cache.set(instance, {
text: markdownState.text,
theme: markdownState.theme,
defaultTextStyle: markdownState.defaultTextStyle,
renderer,
renderedWidth: width,
renderedLines,
});
return renderedLines;
};
}
const renderCachedUserMessageMarkdownLines =
createUserMessageMarkdownLineRenderer();
function renderUserMessageBodyLines(
instance: unknown,
innerWidth: number,
originalRender: (width: number) => string[],
markdownState: UserMessageMarkdownState | undefined,
originalBodyLineCache?: WeakMap<object, CachedUserMessageBodyLines>,
): string[] {
if (typeof instance !== "object" || instance === null) {
return originalRender.call(instance, innerWidth) as string[];
}
if (!markdownState) {
const cached = originalBodyLineCache?.get(instance);
if (cached?.width === innerWidth) {
return cached.lines;
}
const lines = originalRender.call(instance, innerWidth) as string[];
originalBodyLineCache?.set(instance, { width: innerWidth, lines });
return lines;
}
if (shouldBypassUserMessageMarkdownRebuild(markdownState)) {
return originalRender.call(instance, innerWidth) as string[];
}
try {
return renderCachedUserMessageMarkdownLines(
instance,
markdownState,
innerWidth,
);
} catch {
return originalRender.call(instance, innerWidth) as string[];
}
}
export function patchNativeUserMessagePrototype(
prototype: PatchableUserMessagePrototype,
getTheme: () => UserMessageTheme | undefined,
isEnabled: () => boolean,
): void {
const finalOutputCache = new WeakMap<object, CachedUserMessageFinalOutput>();
const originalBodyLineCache = new WeakMap<object, CachedUserMessageBodyLines>();
patchUserMessageRenderPrototype(
prototype,
USER_MESSAGE_PATCH_VERSION,
(originalRender) =>
function renderWithNativeUserBorder(width: number): string[] {
const safeWidth = Math.max(0, Math.floor(width));
if (!isEnabled() || safeWidth < MIN_BORDER_WIDTH) {
return originalRender.call(this, safeWidth) as string[];
}
const canCacheFinalOutput = typeof this === "object" && this !== null;
const markdownState = canCacheFinalOutput
? extractUserMessageMarkdownState(this as { children?: unknown[] })
: undefined;
if (markdownState && shouldBypassUserMessageMarkdownRebuild(markdownState)) {
return originalRender.call(this, safeWidth) as string[];
}
const theme = getTheme();
if (canCacheFinalOutput) {
const cached = finalOutputCache.get(this as object);
if (cached && hasSameFinalOutputState(cached, safeWidth, theme, markdownState)) {
return cached.output;
}
}
const innerWidth = getUserMessageContentWidth(safeWidth);
const lines = renderUserMessageBodyLines(
this,
innerWidth,
originalRender,
markdownState,
originalBodyLineCache,
);
const contentLines = normalizeUserMessageContentLines(lines);
const paddedContentLines = addUserMessageVerticalPadding(
contentLines.length > 0 ? contentLines : [""],
);
const output = [
...Array.from({ length: USER_MESSAGE_TOP_MARGIN_LINES }, () => ""),
buildTopBorder(safeWidth, theme),
...paddedContentLines.map((renderLine) =>
wrapContentLine(renderLine, safeWidth, theme),
),
buildBottomBorder(safeWidth, theme),
];
if (canCacheFinalOutput) {
finalOutputCache.set(
this as object,
toFinalOutputCacheEntry(safeWidth, theme, markdownState, output),
);
}
return output;
},
);
}

View File

@@ -0,0 +1,106 @@
import { ANSI_SGR_PATTERN, sanitizeAnsiForThemedOutput } from "./ansi-utils.js";
const OSC_PROMPT_CONTROL_SEQUENCE_PATTERN = /\x1b\](?:133|633);[A-Z](?:;[^\x07\x1b]*)?(?:\x07|\x1b\\)/g;
const USER_MESSAGE_BACKGROUND = "userMessageBg";
const ANSI_BG_RESET = "\x1b[49m";
const USER_MESSAGE_VERTICAL_PADDING_LINES = 1;
export interface UserMessageBackgroundTheme {
bg?(color: string, text: string): string;
getBgAnsi?(color: string): string;
}
function hasPromptControlOscSequence(text: string): boolean {
return text.includes("\x1b]133;") || text.includes("\x1b]633;");
}
function stripOscPromptControlSequences(text: string): string {
if (!text || !hasPromptControlOscSequence(text)) {
return text;
}
// Strip prompt-control OSC sequences only. OSC 8 hyperlinks are intentionally
// preserved because they carry renderable terminal hyperlink metadata.
return text.replace(OSC_PROMPT_CONTROL_SEQUENCE_PATTERN, "");
}
function sanitizeUserMessageAnsi(text: string): string {
return sanitizeAnsiForThemedOutput(stripOscPromptControlSequences(text));
}
export function applyUserMessageBackground(
theme: UserMessageBackgroundTheme | undefined,
text: string,
): string {
if (!text) {
return text;
}
const sanitized = sanitizeUserMessageAnsi(text);
if (!theme) {
return sanitized;
}
try {
if (typeof theme.getBgAnsi === "function") {
return `${theme.getBgAnsi(USER_MESSAGE_BACKGROUND)}${sanitized}${ANSI_BG_RESET}`;
}
} catch (themeError) {
void themeError;
}
try {
if (typeof theme.bg === "function") {
return theme.bg(USER_MESSAGE_BACKGROUND, sanitized);
}
} catch (themeError) {
void themeError;
}
return sanitized;
}
function isVisuallyEmptyLine(line: string): boolean {
const withoutControlSequences = stripOscPromptControlSequences(line)
.replace(ANSI_SGR_PATTERN, "");
return withoutControlSequences.trim().length === 0;
}
function trimEdgePadding(lines: string[]): string[] {
let start = 0;
while (start < lines.length && isVisuallyEmptyLine(lines[start] ?? "")) {
start++;
}
let end = lines.length;
while (end > start && isVisuallyEmptyLine(lines[end - 1] ?? "")) {
end--;
}
return lines.slice(start, end);
}
export function normalizeUserMessageContentLines(lines: string[]): string[] {
const normalizedLines = trimEdgePadding(lines);
if (normalizedLines.length === 0) {
return [];
}
return normalizedLines;
}
export function normalizeUserMessageContentLine(line: string): string {
if (isVisuallyEmptyLine(line)) {
return "";
}
return sanitizeUserMessageAnsi(line);
}
export function addUserMessageVerticalPadding(lines: string[]): string[] {
const padding = Array.from(
{ length: USER_MESSAGE_VERTICAL_PADDING_LINES },
() => "",
);
return [...padding, ...lines, ...padding];
}

View File

@@ -0,0 +1,31 @@
export interface WriteCallSummaryOptions {
hasContent: boolean;
hasDetailedResultHeader: boolean;
}
export function splitWriteContentLines(content: string): string[] {
if (!content) {
return [];
}
const normalized = content.replace(/\r/g, "");
const lines = normalized.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop();
}
return lines;
}
export function countWriteContentLines(value: unknown): number {
return typeof value === "string" ? splitWriteContentLines(value).length : 0;
}
export function getWriteContentSizeBytes(value: unknown): number {
return typeof value === "string" ? Buffer.byteLength(value, "utf8") : 0;
}
export function shouldRenderWriteCallSummary(
options: WriteCallSummaryOptions,
): boolean {
return options.hasContent && !options.hasDetailedResultHeader;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
export type RuntimeToolDefinition = Record<string, unknown>;
export interface ToolDisplayAdapter {
kind?: "read" | "edit" | "mcp" | "generic";
overrideExistingRenderers?: boolean;
}
export interface ToolDisplayApi {
version: 1;
decorateTool<T extends RuntimeToolDefinition>(tool: T, adapter?: ToolDisplayAdapter | Record<string, unknown>): T;
}
export interface DecorateToolForDisplayOptions {
suppressDecorateErrors?: boolean;
}
export declare function getToolDisplayApi(): ToolDisplayApi | undefined;
export declare function queueToolDisplayDecoration<T extends RuntimeToolDefinition>(
tool: T,
adapter?: ToolDisplayAdapter | Record<string, unknown>,
): void;
export declare function decorateToolForDisplay<T extends object>(
tool: T,
adapter?: ToolDisplayAdapter | Record<string, unknown>,
options?: DecorateToolForDisplayOptions,
): T;
export declare function decorateMcpToolForDisplay<T extends RuntimeToolDefinition>(tool: T): T;

View File

@@ -0,0 +1,40 @@
const TOOL_DISPLAY_API_KEY = Symbol.for("pi-tool-display.api.v1");
const TOOL_DISPLAY_PENDING_DECORATIONS_KEY = Symbol.for("pi-tool-display.pendingDecorations.v1");
export function getToolDisplayApi() {
const api = globalThis[TOOL_DISPLAY_API_KEY];
if (api?.version !== 1 || typeof api.decorateTool !== "function") {
return undefined;
}
return api;
}
export function queueToolDisplayDecoration(tool, adapter) {
const existing = globalThis[TOOL_DISPLAY_PENDING_DECORATIONS_KEY];
const queue = Array.isArray(existing) ? existing : [];
queue.push({ tool, adapter });
globalThis[TOOL_DISPLAY_PENDING_DECORATIONS_KEY] = queue;
}
export function decorateToolForDisplay(tool, adapter, options = {}) {
const api = getToolDisplayApi();
if (!api) {
queueToolDisplayDecoration(tool, adapter);
return tool;
}
try {
return api.decorateTool(tool, adapter);
} catch (error) {
if (options.suppressDecorateErrors) {
return tool;
}
throw error;
}
}
export function decorateMcpToolForDisplay(tool) {
return decorateToolForDisplay(tool, { kind: "mcp", overrideExistingRenderers: true });
}