Files
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

810 lines
43 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Journey 19 — JetBrains Plugin
Authoritative sources:
- Plugin: `packages/jetbrains-lean-ctx/src/main/kotlin/com/leanctx/plugin/{server,endpoint,psi,dto}/…`
- Rust backend: `rust/src/lsp/{backend,jetbrains_backend,router,edit_apply,port_discovery}.rs`
- MCP tool schema: `rust/src/tools/registered/ctx_refactor.rs`
---
## 0. Serena as Inspiration
The lean-ctx JetBrains plugin is conceptually inspired by **Serena** (Oraios'
IntelliJ-Platform MCP tool). Serena was the model because it was the only tool to
deliver the semantic core — `references`, `implementations`, `type_hierarchy` **and**
symbolic edits — directly from the IDE; the official JetBrains MCP
(`mcp__jetbrains__*`) never closed this gap.
**Clear delineation:** The plugin is an **independent reimplementation at the
architecture and class-name level — not a derivation, not decompiled
Serena code**. It is published under the lean-ctx project license and ships in the
repository (`packages/jetbrains-lean-ctx`). Goal: make Serena (and the official
JetBrains MCP) **dispensable** as a code-intelligence dependency, so that
lean-ctx becomes the sole interface for symbols, navigation, and refactoring.
### 0.1 Delineation Serena ↔ lean-ctx Plugin
| Aspect | Serena | lean-ctx JetBrains plugin |
|----------------|----------------------------|--------------------------------------------------------------------------|
| Hosting | external Oraios component | in the lean-ctx repo (`packages/jetbrains-lean-ctx`) |
| Interface | several separate MCP tools | bundled under `ctx_refactor` (token compression) |
| Backend model | running IDE only | Backing B (IDE) **+** Backing A (rust-analyzer) **+** Headless |
| Headless / CI | no | yes — tree-sitter fallback for `symbols_overview` + edits |
| Conflict guard | none | BLAKE3 `expected_hash` (edits) / `plan_hash` (refactoring), Rust-central |
| Security | — | PathJail (project-root validation) + token auth per project |
| License | proprietary (Oraios) | lean-ctx project license |
### 0.2 Mapping: Serena concept → `ctx_refactor` action → HTTP endpoint
| Serena concept | `ctx_refactor` action | HTTP endpoint |
|----------------------------|----------------------------------|-----------------------------------------------------|
| `find_referencing_symbols` | `references` | `POST /references` |
| `find_declaration` | `declaration` | `POST /declaration` |
| (goto definition) | `definition` | `POST /definition` |
| `find_implementations` | `implementations` | `POST /implementations` |
| `get_symbols_overview` | `symbols_overview` | `POST /symbols_overview` |
| `type_hierarchy` | `type_hierarchy` | `POST /type_hierarchy` |
| `run_inspections` / list | `inspections` (`mode=run\|list`) | `POST /inspections`, `POST /list_inspections` |
| `replace_symbol_body` | `replace_symbol_body` | `POST /replaceSymbolBody` |
| `insert_before_symbol` | `insert_before_symbol` | `POST /insertBeforeSymbol` |
| `insert_after_symbol` | `insert_after_symbol` | `POST /insertAfterSymbol` |
| `rename` | `rename` | `POST /renamePreview``POST /renameApply` |
| (reformat_file) | `reformat` | `POST /reformat` |
| `move` | `move` | `POST /movePreview``POST /moveApply` |
| `safe_delete` | `safe_delete` | `POST /safeDeletePreview``POST /safeDeleteApply` |
| `inline` | `inline` | `POST /inlinePreview``POST /inlineApply` |
> `find_symbol` (pure symbol search) is not part of `ctx_refactor` but of
> `ctx_search action="symbol"` / `ctx_outline` (lean-ctx symbol index). See
> [MCP tool map](appendix-mcp-tools.md).
---
## 1. Architecture (Plugin ↔ Rust ↔ MCP tool)
```text
Agent
│ ctx_refactor action=… (MCP)
│ Rust: ctx_refactor → select_backend │
│ IDE reachable? │ no
▼ yes ▼
Backing B Headless / Backing A
JetBrainsHttpBackend • local_range_write (edits, atomic)
HTTP → Plugin • overview_from_index (tree-sitter)
│ • rust-analyzer (navigation)
│ JetBrains IDE plugin (Kotlin HTTP server) │
│ 127.0.0.1 · token-guarded · PSI/read-action │
```
### 1.1 Backing choice & degradation (`backend.rs`)
`select_backend` (`rust/src/lsp/router.rs`) decides per call which path applies.
The `LspBackend` trait tiers the methods:
| Class | Methods | Default without IDE |
|---------------------------------------------|------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|
| **Mandatory** (both backings) | `open_file`, `references`, `definition`, `implementations` | served by Backing A |
| **Default-degrading** (Backing B preferred) | `declaration`, `type_hierarchy`, `inspections`, `list_inspections` | `Err` — "requires the JetBrains backend" |
| **Headless-default** (lossless) | `symbols_overview` (tree-sitter), `replace_symbol_body`, `insert_before_symbol`, `insert_after_symbol` (`local_range_write`) | works without IDE |
| **`BACKEND_REQUIRED`** | refactoring engine (`rename`, `move`, `safe_delete`, `inline`) | `Err` — no headless usage search possible |
### 1.2 Port discovery & staleness
On project start the plugin writes a **port file** (atomic, idempotent) with the
JSON keys `port`, `token`, `pid`, `project_root`, `ide_version`, `started_at`
(snake_case on the wire; `PortFileWriter.kt`, `BackendHttpServer.kt`
`LeanCtxPaths.portFile(dataDir, projectRoot)`). On `projectClosing` (Disposable)
it is deleted. The Rust reader (`PortFile`, `port_discovery.rs`) consumes
`port`/`token`/`pid`/`project_root`/`ide_version`.
Rust checks reachability in **three stages** (`rust/src/lsp/port_discovery.rs`):
1. Port file exists & is readable → `port`/`token`/`pid`,
2. process with `pid` is alive,
3. `GET /health` responds within the timeout.
Only when all three pass is Backing B considered reachable; otherwise Headless
or `BACKEND_REQUIRED` applies.
### 1.3 Worktrees & project windows
The HTTP server is a **project-level service** (`BackendHttpServer` as a
`Disposable`, booted by `LeanCtxStartupActivity` per `Project`, bound to
`127.0.0.1:0` = ephemeral port). The port file is keyed
**per project** via `projecthash = sha256(canonical(projectRoot))[..16]`. From this
follows for `git worktree`:
- **One dedicated port file per worktree** — but only if the worktree is opened as its
own **project window**. Multiple terminals **within one** project window
share **one** port file (terminals do not start a plugin).
- **One open project window serves exactly one worktree path.** A lean-ctx session
running in a **different** worktree computes a diverging `projecthash`,
finds **no** port file → clean **fallback to Backing A** (rust-analyzer);
with `lsp.<lang>="jetbrains"` instead `BACKEND_REQUIRED`. **No** path collision.
- **Backing B for N worktrees in parallel:** one project window per worktree.
A **single** IDE instance suffices — *File → Open → in new window* instantiates the
project service again (own server, own port, own port file). **No**
second IDE installation/process needed.
- **JetBrains VCS ↔ PSI orthogonal:** The Git tool-window confusion with worktrees
(`.git` file → `gitdir:` indirection) concerns the **VCS layer**, not
indexing. The Backing-B endpoints need an **indexed Cargo project**, not a
recognized VCS root → **PSI works** even when the Git panel is acting up.
- **Per terminal** the lean-ctx session must be `cd`'d into the **matching** worktree;
the `projecthash` match then runs automatically.
> Cost trade-off: N project windows = N× indexing/RAM (shared JVM, separate
> indexes). Worth it only with a genuine need for PSI symbolics in multiple worktrees
> **simultaneously** — otherwise leave the secondary worktree in the terminal and
> accept the rust-analyzer fallback (Backing A). The same **branch** cannot be checked
> out in two worktrees at once (git constraint).
---
## 2. Function Reference
Conventions for all endpoints:
- HTTP: `POST` to `127.0.0.1:<port>`, header `X-LeanCtx-Token: <token>`,
body = JSON. `GET /health` is the only exception (no body).
- **Coordinates:** At the `ctx_refactor` level, `line` is **1-indexed**, `column`
is **0-indexed**. At the **wire level** (HTTP DTO), `line`/`character` of the
navigation/edit endpoints are **0-based** (LSP convention); the `line` fields in
`type_hierarchy`, `symbols_overview`, and `inspections` responses are **1-based**.
- Domain negative cases arrive as an envelope `{"error":{"code","message"}}` with
HTTP 200 (see §9).
### 2.1 Navigation (read-only)
**Actions:** `references`, `definition`, `implementations`, `declaration`
**Endpoints:** `POST /references` · `/definition` · `/implementations` · `/declaration`
**What it does:** Finds semantic occurrences of a symbol (usages,
declaration, implementations). `declaration` is only available via Backing B.
**Agent invocation:**
```text
ctx_refactor action=references path=src/Main.kt line=42 column=8 scope=project
```
**HTTP (curl):**
```bash
curl -s -X POST http://127.0.0.1:$PORT/references \
-H "X-LeanCtx-Token: $TOKEN" -H "Content-Type: application/json" \
-d '{"path":"src/Main.kt","line":41,"character":8,"scope":"project"}'
```
**Response (`LocationsResponse`):**
```text
{"locations":[{"path":"src/Main.kt","range":{"start":{"line":41,"character":8},
"end":{"line":41,"character":14}}}],"truncated":false,"total":1}
```
**Parameters:** `path`, `line`/`character` (0-based, wire), `scope ∈ {project, all}`
(default `project`; `all` includes libraries/SDK).
**Backing:** Backing B preferred; Backing A (rust-analyzer) as fallback for
`references`/`definition`/`implementations`. `declaration` is Backing-B-only.
### 2.2 Structure
**Actions:** `type_hierarchy`, `symbols_overview`
**Endpoints:** `POST /type_hierarchy` · `POST /symbols_overview`
**What it does:** `type_hierarchy` returns the super-/subtype tree; `symbols_overview`
lists the top-level symbols of a file.
**Agent invocation:**
```text
ctx_refactor action=type_hierarchy path=src/Main.kt line=10 column=6 direction=subtypes
ctx_refactor action=symbols_overview path=src/Main.kt
```
**HTTP (curl):**
```bash
curl -s -X POST http://127.0.0.1:$PORT/symbols_overview \
-H "X-LeanCtx-Token: $TOKEN" -d '{"path":"src/Main.kt"}'
```
**Response (`SymbolsOverviewResponse`, `line` 1-based):**
```text
{"symbols":[{"name":"Main","kind":"class","line":3},
{"name":"run","kind":"method","line":7}],"truncated":false,"total":2}
```
**Parameters:** `type_hierarchy`: `path`, `line`/`character`, `direction ∈
{supertypes, subtypes}` (default `supertypes`), `scope`. `symbols_overview`: `path`.
**Backing:** `type_hierarchy` is Backing-B-only. `symbols_overview` has a
**lossless headless default** via the tree-sitter symbol index
(`overview_from_index`, the same source as `ctx_search action="symbol"` / `ctx_outline`).
**IDE-neutral loading & degradation.** The Core `plugin.xml` depends only on
`com.intellij.modules.platform`, so it loads in every IntelliJ IDE (RustRover, PyCharm,
GoLand, WebStorm, IDEA, …). The two JVM-PSI-bound structure ops live in an optional
module (`leanctx-jvm.xml`, loaded only when the Kotlin plugin is present) and are wired
through the `com.leanctx.plugin.structureProvider` extension point. In non-JVM IDEs the
EP is empty and the Core degrades cleanly:
| Feature | RustRover (Rust) | PyCharm (Python) | IDEA / Android Studio (JVM) |
|----------------------|-------------------------------------------|-----------------------|------------------------------------|
| Navigation | ✅ Plugin-PSI (Rust) / Backing-A fallback | ✅ Plugin-PSI | ✅ Plugin-PSI |
| `symbols_overview` | ✅ lean-ctx tree-sitter (`ctx_outline`) | ✅ tree-sitter | ✅ IDE-PSI (Kotlin) + tree-sitter |
| `type_hierarchy` | → `implementations` / `ctx_callgraph` | → `implementations` | ✅ IDE-PSI (Java + Kotlin) |
| Edits / Refactor / `reformat` / `inspections` | ✅ Plugin (platform) | ✅ | ✅ |
| UI (Gain, Status-bar, Doctor, Editor-signal) | ✅ | ✅ | ✅ |
For Rust/Python the IDE-PSI variant of `type_hierarchy` and Kotlin `symbols_overview`
is not registered; the Rust backend serves the equivalent via `ctx_outline`
(tree-sitter), `implementations` (rust-analyzer / Backing A) and `ctx_callgraph`.
> **Live-verified (2026-06-13, RustRover-2026.1 / IU-2026.1.3 sandbox).** All 12 cross-IDE
> gate checks passed: the Core loads with no `java-capable` error (`leanctx-jvm.xml` skipped
> via the K2 gate), every Rust feature in the matrix works, and `type_hierarchy` degrades
> with the exact `UNSUPPORTED_LANGUAGE: type_hierarchy requires a JVM-capable IDE` envelope.
> Runbook + result table: `docs/lean-md/runbooks/runrustrover-cross-ide-gate.md`.
### 2.3 Quality — Inspections
**Action:** `inspections` (`mode=run|list`)
**Endpoints:** `POST /inspections` · `POST /list_inspections`
**What it does:** `mode=run` runs the active inspections on a file and
returns diagnostics; `mode=list` lists the inspections enabled in the project profile.
**Agent invocation:**
```text
ctx_refactor action=inspections path=src/Main.kt mode=run
ctx_refactor action=inspections path=src/Main.kt mode=list
```
**Response `run` (`InspectionsResponse`, `line` 1-based):**
```text
{"diagnostics":[{"path":"src/Main.kt","line":12,"severity":"WARNING",
"message":"Unused symbol"}],"truncated":false,"total":1}
```
**Response `list` (`ListInspectionsResponse`):**
```text
{"inspections":[{"id":"UnusedSymbol","name":"Unused declaration",
"severity":"WARNING"}],"truncated":false,"total":1}
```
**Backing:** Backing-B-only (no headless equivalent).
### 2.4 Symbol-body edits (write)
**Actions:** `replace_symbol_body`, `insert_before_symbol`, `insert_after_symbol`
**Endpoints:** `POST /replaceSymbolBody` · `/insertBeforeSymbol` · `/insertAfterSymbol`
**What it does:** Replaces the complete declaration of a named symbol or
inserts a sibling element before/after it. The target is addressed via `name_path`
(`'Class/method'` qualified or bare `'name'`), resolved through the
symbol index. Alternatively as a fallback via `path`+`line`(+`end_line`).
**Agent invocation:**
```text
ctx_refactor action=replace_symbol_body name_path=Main/run \
new_body="fun run() { println(\"new\") }" expected_hash=<blake3-hex>
ctx_refactor action=insert_after_symbol name_path=Main/run \
text="fun helper() = 42"
```
**HTTP (curl) — wire body carries `path`/`range`/`text` (no hash, see §7.1):**
```bash
curl -s -X POST http://127.0.0.1:$PORT/replaceSymbolBody \
-H "X-LeanCtx-Token: $TOKEN" -d '{
"path":"src/Main.kt",
"range":{"start":{"line":6,"character":0},"end":{"line":8,"character":1}},
"text":"fun run() { println(\"new\") }"
}'
```
**Response (`EditResponse`):**
```text
{"applied":true,
"newRange":{"start":{"line":6,"character":0},"end":{"line":6,"character":28}},
"editedText":"fun run() { println(\"new\") }"}
```
**Parameters (action):** `name_path` **or** `path`+`line`(+`end_line`);
`new_body` (replace) or `text` (insert); optional `expected_hash`.
**Behavior:** Backing B executes the edit as a `WriteCommandAction` (a
single undo entry, document save). Headless writes atomically via `local_range_write`
(temp file + `rename`). **Both paths apply the same tree-sitter range
→ byte-identical result.** No automatic reformatting.
---
## 3. Refactoring Engine
All refactorings (except `reformat`) run through the **shared two-phase engine**:
`*Preview` collects usages + conflicts and forms the `plan_hash`; `*Apply`
performs the multi-file change as **one** transaction (one undo entry).
Because the semantic usage search needs the finished IDE index, there is **no**
lossless headless path — without a running IDE you get `BACKEND_REQUIRED`.
### 3.1 Rename (two-phase)
**Action:** `rename` (`new_name`)
**Endpoints:** `POST /renamePreview``POST /renameApply`
**What it does:** Renames a symbol project-wide — declaration **and all usages**.
Phase 1 (`/renamePreview`) collects `usages` and `conflicts` and forms the
`plan_hash` from them; Phase 2 (`/renameApply`) performs the rename as **one**
multi-file transaction.
**Agent invocation:**
```text
ctx_refactor action=rename path=src/Main.kt line=7 column=4 new_name=execute
```
**HTTP (curl) — Phase 1:**
```bash
curl -s -X POST http://127.0.0.1:$PORT/renamePreview \
-H "X-LeanCtx-Token: $TOKEN" -d '{
"path":"src/Main.kt",
"range":{"start":{"line":6,"character":4},"end":{"line":6,"character":7}},
"new_name":"execute","search_comments":false,"search_text_occurrences":false
}'
# → {"usages":[{"path":"src/Main.kt","range":{…},"context":"run()"}],"conflicts":[]}
```
**HTTP (curl) — Phase 2:**
```bash
curl -s -X POST http://127.0.0.1:$PORT/renameApply \
-H "X-LeanCtx-Token: $TOKEN" -d '{
"path":"src/Main.kt","range":{…},"new_name":"execute","force":false
}'
# → {"applied":true,"changed_paths":["src/Main.kt","src/Caller.kt"]}
```
**Parameters:** `new_name` (required); optional `search_comments`,
`search_text_occurrences` (preview); `force` (apply — skips the conflict gate).
**Behavior:** `BACKEND_REQUIRED` without a running IDE. If conflicts exist and
`force=false`, the gate blocks with `CONFLICT`. Between preview and apply the
`plan_hash` (BLAKE3, Rust-central) protects against TOCTOU drift.
### 3.2 Reformat
**Action:** `reformat`
**Endpoint:** `POST /reformat`
**What it does:** Formats a file in place according to the IDE's active code-style
profile (`CodeStyleManager` — equivalent to `mcp__jetbrains__reformat_file`).
Single-phase (no preview): formatting is idempotent and scoped to one file.
**Agent invocation:**
```text
ctx_refactor action=reformat path=src/Main.kt
```
**HTTP (curl):**
```bash
curl -s -X POST http://127.0.0.1:$PORT/reformat \
-H "X-LeanCtx-Token: $TOKEN" -d '{"path":"src/Main.kt"}'
# → {"reformatted":true,"path":"src/Main.kt"}
```
**Behavior:** Backing-B-only (`WriteCommandAction``CodeStyleManager.reformat`
`saveDocument`). Deliberately **decoupled** from the edit ops: symbol-body edits
do not reformat automatically; `reformat` is applied afterward when needed.
### 3.3 Move
**Action:** `move`
**Endpoints:** `POST /movePreview``POST /moveApply`
**What it does:** Moves a symbol (class/file/member) into another
package/target and adjusts all references + imports. Same two-phase mechanic as
`rename`: preview reports affected files + conflicts (`plan_hash`), apply performs
the multi-file transaction. `BACKEND_REQUIRED` without IDE.
### 3.4 Safe Delete
**Action:** `safe_delete`
**Endpoints:** `POST /safeDeletePreview``POST /safeDeleteApply`
**What it does:** Deletes a symbol only if no blocking usages
exist. Preview reports the found usages as conflicts; apply deletes (or
blocks with `CONFLICT` unless `force`). Same engine as `rename`.
### 3.5 Inline
**Action:** `inline`
**Endpoints:** `POST /inlinePreview``POST /inlineApply`
**What it does:** Replaces a symbol with its body at all call sites and
removes the declaration. Preview reports the affected sites + conflicts; apply
performs the multi-file replacement. Same engine as `rename`.
---
## 4. Gain Tool Window
A dockable bottom tool window (`LeanCtxGain`) that renders the rich
`lean-ctx gain` report inside the IDE — a hero Gain Score, four sub-scores, a
task-category table and a top-files heatmap, plus a footer with the model name
and refresh age. It is a read-only consumer; the existing status-bar widget keeps
its cheap local `StatsReader` and merely acts as one of the triggers.
### 4.1 Data flow
`GainService.load()` spawns `lean-ctx gain --json` as a **subprocess** (via the
shared `BinaryResolver.runCommand`, off the EDT) with a **10-second timeout**
shorter than the status bar's 30 s so a hung binary surfaces an error quickly.
The captured stdout is parsed by `GainCodec.parse` (Gson, `disableHtmlEscaping`),
which maps the snake_case JSON payload onto typed DTOs via `@SerializedName`. The
service classifies the outcome into a typed `GainLoadResult`, which the panel maps
1:1 onto one of four UI states:
| `GainLoadResult` | Trigger | Panel state |
|------------------|-----------------------------------------------|-----------------------------------------------|
| `Ok(data)` | exit 0, parsed, has data | data view (hero, sub-scores, tables, footer) |
| `Empty` | exit 0 but `tokens_saved == 0` and 0 commands | "no data captured yet" |
| `BinaryNotFound` | stderr contains `binary not found` | hint to run `lean-ctx setup` / check PATH |
| `Failed(reason)` | exit ≠ 0, timeout (exit `-1`), or parse error | error message + stderr excerpt + retry button |
Choosing a subprocess over the existing HTTP backend is deliberate: that backend
is **plugin-as-server** (Rust queries the IDE for PSI), which is the wrong
direction here — the tool window is the consumer and Rust is the producer. The
subprocess keeps the `GainScore` logic as the single source of truth in Rust;
Kotlin only renders.
### 4.2 Schema contract (DTO keys)
Because `gain --json` is effectively the tool window's API, its top-level keys are
pinned against the Kotlin DTOs (`dto/GainData.kt`) by a Rust drift test
(`e82ddbec`) — a schema change breaks the test instead of silently breaking the
plugin. Only the rendered subset is parsed; extra payload keys (`model`,
`energy_wh`, `co2_grams`, `roi`, …) are ignored by Gson.
| JSON key | DTO | Notes |
|---------------------------|------------------------------|------------------------------------------------------------------------------|
| `summary` | `GainSummaryDTO` | hero + sub-scores root |
| `summary.model.model_key` | `ModelDTO.modelKey` | footer model name |
| `summary.tokens_saved` | `GainSummaryDTO.tokensSaved` | hero |
| `summary.gain_rate_pct` | `GainSummaryDTO.gainRatePct` | hero |
| `summary.avoided_usd` | `GainSummaryDTO.avoidedUsd` | hero |
| `summary.score` | `ScoreDTO` | `total`, `compression`, `cost_efficiency`, `quality`, `consistency`, `trend` |
| `tasks[]` | `TaskRow` | `category`, `commands`, `tokens_saved`, `tool_calls`, `tool_spend_usd` |
| `heatmap[]` | `FileRow` | `path`, `access_count`, `tokens_saved`, `compression_pct` |
The `tasks` and `heatmap` arrays default to empty when absent (Gson bypasses the
Kotlin constructor defaults, so `GainCodec.parse` normalizes them post-parse).
### 4.3 Visibility-gated polling
`GainPollController` is **visibility-gated** via
`ToolWindowManagerListener.stateChanged` + `toolWindow.isVisible`: it loads
**immediately** when the window becomes visible (no initial delay), then polls on
a 30 s timer only while the window stays visible. Hiding, detaching or switching
tabs stops the timer at once — no subprocess is spawned while the window is not
shown. A manual refresh button in the toolbar forces an immediate reload, and the
timer is bound to a `Disposable` on the tool-window content for cleanup on close.
### 4.4 Triggers
Two entry points open the window, both referencing the `GAIN_TOOL_WINDOW_ID`
constant (`"LeanCtxGain"`) rather than a string literal:
- **Status-bar click** — `LeanCtxStatusBarWidget` activates the tool window via
its click consumer.
- **Tools menu → "Gain Report"** — the existing `GainAction` was repurposed to
activate the tool window instead of showing a text popup.
### 4.5 Output hygiene
Command output is stripped of ANSI escape sequences before display
(`util/AnsiText.stripAnsi`, fix `b933e510`) so colored CLI output never leaks raw
escape codes into the Swing panel or the command-result popups.
---
## 5. Editor-Focus Reporter
The plugin reports the path of the focused editor file to lean-ctx so the
context engine can rank it up. This is the **JetBrains producer side of #500
(editor focus)** — 1:1 parity with the VS Code producer
(`vscode-extension/src/editor-signal.ts`). Until this was added, JetBrains users
got none of the #500 ranking boost; the reporter
(`EditorFocusReporter`, wired in `LeanCtxStartupActivity`) closes that gap.
**Privacy — path only, never content.** The signal carries nothing but the
absolute file path. The file's contents are never read, hashed, or transmitted.
Only real, local files **inside the current project** are reported (no
scratch/decompiled/library buffers, no directories).
### 5.1 Mechanism (producer side)
The reporter mirrors the focused-file path into lean-ctx's existing #500 ingress;
it does **not** introduce a new signal format or daemon:
- **Trigger:** a focused-file change (`FileEditorManagerListener.selectionChanged`)
and the initially open file at project start both call
`EditorFocusReporter.onFileFocused(file)`.
- **Filter:** the file must be `isInLocalFileSystem`, not a directory, and sit
under `project.basePath` (segment-boundary check, so `/foo/bar2` is not treated
as under `/foo/bar`). Anything else is dropped.
- **Dedup + debounce:** the same path back-to-back is skipped (`lastSent`); a 2 s
pooled-thread `Alarm` (`DEBOUNCE_MS = 2_000`, identical to VS Code) collapses
rapid tab hops to a single emission.
- **Emit:** fire-and-forget on a pooled thread (never the EDT) — it shells out to
the resolved binary as `lean-ctx editor-signal --file <absPath>` (via
`BinaryResolver`). The Rust side (`core::editor_signal::record_focus`) is the
**single source of truth** for the on-disk format
(`~/.lean-ctx/editor_signal.json`, `recent_files` ring, path normalization,
freshness) — the plugin only passes the path, so there is no Kotlin drift of
the signal format. The consumer (`apply_boost` in `ctx_preload`) then lifts
matching ranking candidates.
A missing or too-old binary (no `editor-signal` subcommand) and any spawn/IO error
are swallowed silently — a lost signal is harmless, the next focus change resends.
The debounce `Alarm` is bound to a project-scoped `Disposable`, so it is cancelled
on project close (no leak, no spawn after close).
> **Known limit (inherited from #500, not a JetBrains regression):**
> `editor_signal.json` is a single **global** file, so multiple IDE/editor windows
> are last-write-wins. This is identical to VS Code's behavior; per-window
> correctness would be an editor-agnostic #500 core change and is out of scope.
### 5.2 Opt-out (registry key)
The reporter is **on by default**. It can be disabled via the built-in IntelliJ
registry key `leanctx.editor.signal.enabled` (default **`true`**), evaluated
producer-side on every focus event:
| Registry key | Default | Effect when `false` |
|---------------------------------|---------|--------------------------------------------------------|
| `leanctx.editor.signal.enabled` | `true` | no signal is emitted on focus change (no binary spawn) |
Toggling the key takes effect on the next focus change — no IDE restart needed. A
registry key (rather than a visible settings page) was chosen deliberately: the
signal is a path-only ranking hint and the plugin has no other config layer, so a
power-user opt-out with minimal surface is sufficient.
---
## 6. IDE UI Integration
Beyond the headless HTTP surface (§2–§3), the plugin ships three user-facing IDE
touchpoints: a status-bar widget, a `lean-ctx` Tools menu, and explicit K2
(Kotlin-2 compiler mode) support. All three are registered in
`META-INF/plugin.xml`.
### 6.1 Status-bar widget
The widget shows real-time token savings and is registered as a
`statusBarWidgetFactory` with `id="com.leanctx.statusBar"` and
`order="after encodingWidget"` — so it sits immediately right of the encoding
indicator in the IDE status bar.
- **Factory** (`LeanCtxStatusBarFactory`): `isAvailable`/`canBeEnabledOn` both
return `true`; `createWidget` produces a `LeanCtxStatusBarWidget`, disposed via
`Disposer.dispose`.
- **Widget** (`LeanCtxStatusBarWidget`, a `StatusBarWidget.TextPresentation`):
on `install` it renders once and then arms a daemon `Timer` that re-reads the
stats **every 30 s** and calls `statusBar.updateWidget(ID())`.
- **Text:** `⚡ <N> saved` (e.g. `⚡ 12.4K saved`) when savings are positive,
otherwise the idle label `⚡ lean-ctx`. The tooltip reads
`lean-ctx: <N> tokens saved · <M> commands`, or `lean-ctx: No stats yet` when
no stats file exists.
- **Click → Gain Tool Window:** the click consumer calls
`ToolWindowManager.getInstance(project).getToolWindow(GAIN_TOOL_WINDOW_ID).activate(null)`
— the same `GAIN_TOOL_WINDOW_ID` constant documented in §4, so a click on the
widget opens the Gain Tool Window.
**Stats source** (`StatsReader` + `LeanCtxStats`): `StatsReader.read()` reads
`~/.lean-ctx/stats.json` and regex-extracts the long fields
`total_input_tokens`, `total_output_tokens`, `total_commands` (missing file or
parse error → `null`, never throws). `tokensSaved` mirrors the Rust source of
truth `input.saturating_sub(output)`:
`(totalInputTokens totalOutputTokens).coerceAtLeast(0)`. `formattedSavings()`
renders `M`/`K`/raw with a `Locale.US` decimal point. The same reader feeds the
Gain panel (§4).
### 6.2 Tools menu (`lean-ctx`)
`plugin.xml` registers an action group `LeanCtx.Menu` (`text="lean-ctx"`,
`popup="true"`) added to the IDE `ToolsMenu` (anchor `last`). It contains four
actions:
| Action | ID | Runs |
| ------------ | ----------------- | ----------------------------------------------------- |
| Setup | `LeanCtx.Setup` | `lean-ctx setup` — output in a Messages popup |
| Doctor | `LeanCtx.Doctor` | `lean-ctx doctor` — output in a Messages popup |
| Gain Report | `LeanCtx.Gain` | opens the Gain Tool Window (`GAIN_TOOL_WINDOW_ID`) |
| Dashboard | `LeanCtx.Dashboard` | `lean-ctx dashboard` — fire-and-forget |
- **Base class:** `SetupAction` and `DoctorAction` extend the abstract
`LeanCtxCommandAction(vararg args)` (in `actions/LeanCtxActions.kt`). Its
`actionPerformed` runs `BinaryResolver.runCommand(*args)`, takes the captured
`stdout` (falling back to `stderr` when blank), pipes it through `stripAnsi`,
and shows the result in a `Messages.showInfoMessage` popup titled `lean-ctx`.
So `SetupAction = LeanCtxCommandAction("setup")` and
`DoctorAction = LeanCtxCommandAction("doctor")` differ only by their argument.
- **`GainAction`** extends `AnAction` directly and only activates the Gain Tool
Window via `GAIN_TOOL_WINDOW_ID` — it spawns no binary.
- **`DashboardAction`** extends `AnAction` directly and calls
`BinaryResolver.runCommand("dashboard")` fire-and-forget (no popup; the CLI
opens its own dashboard).
**ANSI strip** (`util/AnsiText.kt`, `stripAnsi`): the `lean-ctx` CLI emits ANSI
CSI escape sequences (colour/SGR) that a Swing `Messages` dialog cannot render.
`stripAnsi` removes them with the regex `\[[0-9;?]*[ -/]*[@-~]` before the
captured output is shown, so the Setup/Doctor popups display clean text rather
than raw escape codes.
### 6.3 K2 mode
The plugin declares K2 support via
`<supportsKotlinPluginMode supportsK2="true"/>` (under the
`org.jetbrains.kotlin` extension namespace). K2 is the Kotlin-2 compiler/analysis
mode of the Kotlin IDE plugin; this declaration tells the IDE the plugin is
compatible with the K2 frontend, so it remains enabled when the user runs the
IDE in K2 mode. The plugin's PSI/navigation/refactoring operations (§2–§3) work
under both the legacy and the K2 Kotlin plugin modes.
The `<supportsKotlinPluginMode supportsK2="true"/>` declaration lives in the optional
`leanctx-jvm.xml` module (not in the Core `plugin.xml`), because it references the
`org.jetbrains.kotlin` namespace. It is loaded only in JVM-capable IDEs where the Kotlin
plugin is present; non-JVM IDEs (RustRover, PyCharm) never parse this block, which is
what keeps the Core free of any hard `java-capable` / Kotlin dependency.
---
## 7. Behavioral Guarantees & Guards
### 7.1 BLAKE3 conflict guard (Rust-central)
The `expected_hash` (edits) or `plan_hash` (refactoring) is a **BLAKE3 hex**
(`crate::core::hasher::hash_hex`) and is checked **exclusively in Rust** — the
plugin does not hash and does not know the field in the wire protocol (`EditRequest`
carries only `path`/`range`/`text`).
- **Headless:** `local_range_write` reads the current bytes of the range, compares
against `expected_hash`, and aborts on divergence with `CONFLICT: range hash
mismatch` — the file stays unchanged.
- **IDE (Backing B):** Rust checks the same hash against the disk bytes **before** the
HTTP POST. So the guard is identical on both paths (same disk bytes,
same BLAKE3 check).
This prevents blindly overwriting externally modified locations.
### 7.2 Smart mode, language, PathJail
- **Smart mode:** If the IDE is in dumb mode (index being built),
PSI operations return `INDEXING` instead of a partial result (no automatic waiting).
For the refactoring engine this is mandatory: an incomplete usage set would be
a broken refactoring.
- **Language:** If an LSP configuration is missing (Backing A) or a PSI processor
(Backing B), `UNSUPPORTED_LANGUAGE` is returned (defensive, nullable EP resolution).
- **PathJail:** Every file operation is validated against the `project_root` before
execution — both the name_path/position resolution and every
`usage`/`changed_path` returned by the plugin.
### 7.3 Idempotency & atomicity
| Operation | Transaction | Idempotent |
|----------------------------------------------|------------------------------------------------|-------------------------------|
| Navigation, structure, inspections | smart-mode read action | yes (index-stable) |
| Symbol-body edits | `WriteCommandAction` (IDE) / atomic (headless) | protected via `expected_hash` |
| Refactoring (rename/move/safe_delete/inline) | multi-file `WriteCommandAction` | protected via `plan_hash` |
| Reformat | `WriteCommandAction` (single file) | yes (formatting-stable) |
Headless writes are atomic (temp file `.<name>.lean-ctx.tmp.<pid>` + `rename`,
`local_range_write` in `rust/src/lsp/edit_apply.rs`).
### 7.4 Cache coherence
After every write, lean-ctx evicts the file from the cache; the next `ctx_read`
re-validates via mtime (~13 tokens). The `editedText` of the `EditResponse` allows an
immediate rewarm; for multi-file refactoring each `changed_path` is mtime-checked.
---
## 8. Authentication & Security
- **Token per project:** On start the plugin generates a random token
(`SecureRandom`, hex), stored in the port file. It is checked on every HTTP request
via the header **`X-LeanCtx-Token`**.
- **401 on missing/mismatch:** `headerToken != token`
`HttpResult(401, {"error":{"code":"UNAUTHORIZED",…}})` — no processing.
- **Loopback only:** The HTTP server listens on `127.0.0.1` (not exposed on the
network) and runs in the IDE user context.
- **Rotation:** On IDE restart a new port file with a new token is created.
See also [Journey 13 — Security & Governance](13-security-and-governance.md).
---
## 9. Error Catalog
**HTTP status:** `200` = success **or** domain negative case (envelope); `401`
= token missing/wrong; `404` = no route for `METHOD /path`; `500` = a real,
unexpected exception. (An `IllegalArgumentException`, e.g. an empty body, is returned
as `200` + `INTERNAL`.)
**Envelope:** `{"error":{"code":"<CODE>","message":"<text>"}}`
| Code | Trigger | Source | Remedy |
|-------------------------|--------------------------------------------------------------------------------|--------------------------------------------|----------------------------------------------------|
| `UNAUTHORIZED` | token missing/wrong (401) | plugin (`RequestRouter`) | send a valid `X-LeanCtx-Token` |
| `NOT_FOUND` | unknown route (404) | plugin | check the endpoint path |
| `FILE_NOT_FOUND` | file not readable | Rust (`edit_apply`) / plugin | verify the path with `ctx_tree` |
| `POSITION_OUT_OF_RANGE` | line/column past EOF / `end < start` | Rust / plugin | re-resolve the range (`ctx_read`) |
| `CONFLICT` | `expected_hash`/`plan_hash` mismatch; or conflicts ∧ `!force` | Rust | read fresh, refresh the hash; if needed `force` |
| `AMBIGUOUS_SYMBOL` | `name_path` matches >1 symbol | Rust (`ctx_refactor`) | qualify (`Class/method`) — note the candidate list |
| `NO_SYMBOL` | `name_path` / target range matches 0 symbols | Rust / plugin (refactor) | correct the name/path |
| `NO_SYMBOL_AT_POSITION` | no resolvable element/reference at the given `line:character` | plugin (nav/structure PSI) | re-resolve the position (`ctx_read`) |
| `INVALID_TARGET` | unknown move/reformat scope kind, or move destination missing/not a directory | plugin (`SymbolMover`/`SymbolReformatter`) | fix the `target`/`scope` kind or destination path |
| `INDEXING` | IDE in dumb mode | plugin (`PsiLocator`) | wait until indexing is finished, retry |
| `UNSUPPORTED` | refactoring engine refused the operation (e.g. recursive/non-inlinable symbol) | plugin (`SymbolInliner`) | pick a different symbol/operation |
| `UNSUPPORTED_LANGUAGE` | no LSP config / no PSI processor; or `type_hierarchy`/IDE-PSI `symbols_overview` in a non-JVM IDE (empty `structureProvider` EP) | Rust / plugin | language is not (yet) supported; for structure ops use `ctx_outline`/`implementations`/`ctx_callgraph` |
| `BACKEND_REQUIRED` | refactoring without a running IDE | Rust (trait default) | start the IDE with an open project |
| `INTERNAL` | other error / parse | both | check `message`; report a bug if needed |
---
## 10. End-to-End Examples
**Example 1 — Replace a function body conflict-safely.**
```text
# 1. fetch the current range + hash (ctx_read delivers bytes; hash = BLAKE3 of the range)
ctx_refactor action=symbols_overview path=src/Main.kt # find symbol + line
# 2. replace, secured against the expected hash
ctx_refactor action=replace_symbol_body name_path=Main/run \
new_body="fun run() { println(\"v2\") }" expected_hash=<blake3-hex>
# → applied:true ; on a concurrent change → CONFLICT (file untouched)
```
**Example 2 — Project-wide rename (two-phase).**
```text
# Phase 1: preview — see usages + conflicts
ctx_refactor action=rename path=src/Main.kt line=7 column=4 new_name=execute
# internal: POST /renamePreview → {usages:[…], conflicts:[]}
# Phase 2: with empty conflicts, apply automatically (one transaction, one undo)
# internal: POST /renameApply → {applied:true, changed_paths:[…]}
```
**Example 3 — Reformat a file (after an edit).**
```text
ctx_refactor action=replace_symbol_body name_path=Main/run new_body="…"
ctx_refactor action=reformat path=src/Main.kt # apply code style afterward
# → {"reformatted":true,"path":"src/Main.kt"}
```
---
## 11. Cross-references & Sources
- [Concise agent reference](appendix-jetbrains-plugin.md) — tables for quick lookup
- [Per-IDE quickstarts](appendix-ide-quickstarts.md) — setup for JetBrains IDEs
- [MCP tool map](appendix-mcp-tools.md) — all MCP tools incl. `ctx_refactor`, `ctx_search`
- [Journey 4 — Code Intelligence](04-code-intelligence.md)
- [Journey 13 — Security & Governance](13-security-and-governance.md) — PathJail, auth
- Source code: `rust/src/lsp/{backend,jetbrains_backend,router,edit_apply,port_discovery}.rs`,
`rust/src/tools/registered/ctx_refactor.rs`,
`packages/jetbrains-lean-ctx/src/main/kotlin/com/leanctx/plugin/{server,endpoint,psi,dto}/…`