项目文件夹

文件
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 行
43 KiB
Markdown

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
# 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}/…`