Files
yvgude--lean-ctx/docs/reference/19-jetbrains-plugin.md
T
wehub-resource-sync 26382a7ac6
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
JetBrains Plugin / Actionlint (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (rust) (push) Waiting to run
JetBrains Plugin / Validation (push) Waiting to run
JetBrains Plugin / Build (push) Waiting to run
JetBrains Plugin / Test (push) Blocked by required conditions
Security Check / Security Scan (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

43 KiB
Raw Blame History

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 /renamePreviewPOST /renameApply
(reformat_file) reformat POST /reformat
move move POST /movePreviewPOST /moveApply
safe_delete safe_delete POST /safeDeletePreviewPOST /safeDeleteApply
inline inline POST /inlinePreviewPOST /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.


1. Architecture (Plugin ↔ Rust ↔ MCP tool)

   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.ktLeanCtxPaths.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:

ctx_refactor action=references path=src/Main.kt line=42 column=8 scope=project

HTTP (curl):

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):

{"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:

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):

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):

{"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:

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):

{"diagnostics":[{"path":"src/Main.kt","line":12,"severity":"WARNING",
 "message":"Unused symbol"}],"truncated":false,"total":1}

Response list (ListInspectionsResponse):

{"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:

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):

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):

{"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 /renamePreviewPOST /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:

ctx_refactor action=rename path=src/Main.kt line=7 column=4 new_name=execute

HTTP (curl) — Phase 1:

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:

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:

ctx_refactor action=reformat path=src/Main.kt

HTTP (curl):

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 (WriteCommandActionCodeStyleManager.reformatsaveDocument). 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 /movePreviewPOST /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 /safeDeletePreviewPOST /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 /inlinePreviewPOST /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 clickLeanCtxStatusBarWidget 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 != tokenHttpResult(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.


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.

# 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).

# 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).

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