docs+test: document LSP code intelligence; cover the LSP tools

- README.md: list definition/references/diagnostics + multi_edit in the tools
  overview; add a Code intelligence (LSP) section (lazy per-language servers,
  required binaries, post-edit sync, freshness wait); add a lspServers config
  example.
- src/tools/codeIntel.test.ts: mock lspManager and assert each tool dispatches
  the right args (path/cwd, 1-indexed positions, includeDeclaration default true)
  and returns the manager result verbatim — no servers spawned. 7 tests.

Verified: typecheck clean, 263 tests pass (+7).
This commit is contained in:
kim
2026-08-21 13:45:40 +09:00
parent f2ca1543d2
commit c8cc78e8f1
2 changed files with 76 additions and 1 deletions
+8 -1
View File
@@ -90,7 +90,7 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
- **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history.
- **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url <url>` for anything else that speaks the same API.
- **Tools**: `read_file`, `list_files`, `grep`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running.
- **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm <mode>`.
- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on.
- **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told.
@@ -105,6 +105,8 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde
- **Tool-calling mode**: on connect, locode probes whether the model reliably uses native OpenAI-style function calling. If not, it switches to a prompt-based fallback mode where the model is instructed to emit tool calls as fenced ` ```tool_call ``` ` JSON blocks, which locode parses itself. The result is cached per backend+model so future sessions skip the probe. Override with `--tool-mode native|fallback|auto` or the in-session `/mode` command.
- **Context tracking & compaction**: the status bar shows `ctx NN%` — context window usage, from real `usage.prompt_tokens` when the backend reports it (requested via `stream_options.include_usage`), or a `~`-prefixed char-based estimate otherwise. The window size itself is auto-detected (Ollama's `/api/show`, then LM Studio's `/api/v0/models`) and cached per backend+model; falls back to a configurable default (`locode config set contextWindow <n>`, or `$LOCODE_CONTEXT_WINDOW`) if neither responds. At 85% usage, locode automatically asks the model to summarize the conversation and replaces the history with that summary (a notice tells you when this happens) — or trigger it yourself anytime with `/compact`.
- **Code intelligence (LSP)**: `definition`, `references`, and `diagnostics` use a real language server (LSP) for go-to-definition, find-all-references, and type/syntax error checks — the same engine an editor's Problems panel uses, more precise than `grep`. A server is lazily started per language on first use and reused for the whole session: `typescript-language-server` (TypeScript/JavaScript), `pyright-langserver` (Python), `gopls` (Go), `rust-analyzer` (Rust), and `clangd` (C/C++ — one clangd covers both). The relevant server binary must be on your PATH; if it isn't, the tool returns a clear "install X" error. After any edit, locode syncs the file to the live server so a subsequent `diagnostics` call reflects the change (it waits for the server to publish fresh diagnostics rather than reading a stale snapshot). Add or override servers with `locode config set lspServers` (see Config).
Note: even models with genuine native tool-calling support occasionally emit a tool call as plain text instead of a real structured call — this is model sampling variance, not a bug. If a turn seems to "describe" a tool call instead of running it, just ask again or try `/mode fallback`.
## Slash commands
@@ -144,6 +146,11 @@ locode config set autoCompactThreshold 0.85 # fraction of context window at whi
locode config set requestTimeoutMs 300000 # per-request timeout in ms (default 180000); raise this if
# your backend queues requests behind a concurrency limit
# (e.g. Ollama's OLLAMA_NUM_PARALLEL) under multi-session load
locode config set lspServers '{"java":{"command":"jdtls","extensions":[".java"]}}' # add a language server
# (JSON object keyed by language id; built-in ids
# override command/args, new ids add support and
# require extensions). Built-ins: typescript,
# python, go, rust, c (C/C++ share clangd).
locode config get
locode config path
```
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// The LSP tools (definition/references/diagnostics) are thin dispatchers over lspManager. We mock
// the manager functions so the tests run without spawning a language server, and assert each tool
// forwards the right arguments (path resolved against cwd, 1-indexed→handled by the manager,
// includeDeclaration default) and returns the manager's result verbatim.
vi.mock("../codeintel/lspManager.js", () => ({
getDefinition: vi.fn(async () => ({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
getReferences: vi.fn(async () => ({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] })),
getDiagnostics: vi.fn(async () => ({
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
})),
}));
import { definitionTool, referencesTool, diagnosticsTool } from "./codeIntel.js";
import { getDefinition, getReferences, getDiagnostics } from "../codeintel/lspManager.js";
const ctx = { cwd: "/proj" };
describe("definition tool", () => {
beforeEach(() => vi.clearAllMocks());
it("forwards path/line/column/cwd to getDefinition and returns its result", async () => {
const out = await definitionTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(getDefinition).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj");
expect(out).toEqual({ definitions: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
});
it("is read-only (no permission prompt)", () => {
expect(definitionTool.mutating).toBe(false);
});
});
describe("references tool", () => {
beforeEach(() => vi.clearAllMocks());
it("defaults includeDeclaration to true when omitted", async () => {
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", true);
});
it("passes an explicit includeDeclaration through", async () => {
await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5, include_declaration: false }, ctx);
expect(getReferences).toHaveBeenCalledWith("src/a.ts", 3, 5, "/proj", false);
});
it("returns the manager's references result", async () => {
const out = await referencesTool.handler({ path: "src/a.ts", line: 3, column: 5 }, ctx);
expect(out).toEqual({ references: [{ path: "/abs/a.ts", line: 3, column: 5 }] });
});
});
describe("diagnostics tool", () => {
beforeEach(() => vi.clearAllMocks());
it("forwards path/cwd to getDiagnostics and returns its result", async () => {
const out = await diagnosticsTool.handler({ path: "src/a.ts" }, ctx);
expect(getDiagnostics).toHaveBeenCalledWith("src/a.ts", "/proj");
expect(out).toEqual({
diagnostics: [{ path: "/abs/a.ts", line: 1, column: 1, severity: "error", message: "oops" }],
});
});
it("is read-only", () => {
expect(diagnosticsTool.mutating).toBe(false);
});
});