Skip to content

Commit 648ba36

Browse files
committed
feat(lsp): 默认启用 LSP + 增量诊断 + VSCode Bridge LSP Backend + TUI 诊断渲染增强
- 默认启用 LSP:未配置 lsp 时视为 true(cfg.lsp === false 才禁用), 降低使用门槛。LSP 按需启动(touchFile 时 spawn),不影响启动性能。 - 回移 PR anomalyco#30226:read.ts warm 从 Effect.ignore 改为 Effect.ignoreCause, 隔离后台 LSP 预热 defect 避免逸出到 read scope。 - 增量诊断策略:编辑后只显示新引入的错误(reportDelta),不重复展示 预存错误。借鉴 Copilot "new errors introduced by a file edit" 语义, 避免模型误判编辑失败(issue anomalyco#9102)。 - MAX_PER_FILE 从 20 降至 5:5 条足以指示问题,减少 token 消耗。 - metadata.diagnostics 统一存储新错误数组(delta),diagnosticSummary 存储摘要供 TUI 渲染紧凑状态行(✓ 0 new · N existing)。 - VSCode Bridge LSP Backend:新增 sdks/vscode/src/lsp.ts 暴露 VSCode 的 LSP 能力(诊断/hover/定义/引用/符号),通过 bridge HTTP 端点传输。 opencode 端 LSP Service 优先走 bridge,无 bridge 时回退到内置 LSP。 对 agent 完全透明,有 bridge 时零安装全语言覆盖。 - TUI DiagnosticsDisplay 增强:有新错误显示详情,无新错误但有已存在 错误显示紧凑摘要,无错误不显示。apply_patch 添加诊断渲染(此前缺失)。 SMARK-Source-Commit: 355c915 SMARK-Patch-Index: 401
1 parent 418e0af commit 648ba36

14 files changed

Lines changed: 3319 additions & 80 deletions

File tree

‎docs/lsp-complete-enhancement-plan.md‎

Lines changed: 1516 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/lsp-default-enable-plan.md‎

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
# LSP 默认启用方案
2+
3+
> 只调研,不改代码。本方案基于穷尽式探索产出,待 subagent 审计通过后再实施。
4+
5+
---
6+
7+
## 一、问题分析
8+
9+
### 用户反馈
10+
"很多情况下都显示 LSP disabled",说明 LSP 服务器未能正常启动或配置。当前需要特定配置或环境才能启用 LSP,不易使用。
11+
12+
### 根因调研(四个独立来源)
13+
14+
| # | 原因 | 证据 | 影响面 |
15+
| --- | --- | --- | --- |
16+
| 1 | **LSP 全局默认禁用** | `config.ts:230` `lsp: Schema.optional(ConfigLSP.Info)` — 不设置就为 `undefined`;`lsp.ts:157` `if (!cfg.lsp)` — `undefined` 走禁用分支 | **所有未配置 `lsp` 的用户**(最大门槛) |
17+
| 2 | 环境缺少语言运行时 | `server.ts` 中 12 个服务器无自动安装(deno, oxlint, ty, prisma, dart, ocaml, gleam, clojure, nix, haskell, julia, terraform);部分服务器需 go/ruby/dotnet/elixir/zig | 特定语言用户 |
18+
| 3 | 项目识别条件未满足 | `server.ts:102` TypeScript 需 `Module.resolve("typescript/lib/tsserver.js")`,项目无 TS 依赖则不启动 | 特定项目 |
19+
| 4 | UI 统一显示 "disabled" | `lsp.ts:60` Status schema 只有 "connected"/"error",不区分原因 | 诊断体验 |
20+
21+
### 官方态度
22+
- **PR #23416**(commit `22190bd`)试图把 `lsp` 默认改为 `true`,**被关闭**。维护者明确说"LSP is intentionally disabled by default"。
23+
- **PR #32876**(Open)是文档修复,明确说明 LSP 默认禁用。
24+
- **Issue #23566 / #27537** 报告文档暗示 LSP 默认启用但实际禁用。
25+
26+
### 其他工具对比
27+
| 工具 | LSP 默认启用 | 自动安装 | 自包含程度 |
28+
| --- | --- | --- | --- |
29+
| **OpenCode** | ❌ 需 `"lsp": true` | ✅ 混合(npm/go/gem/dotnet/fetch) | 中(高于竞品) |
30+
| Claude Code | ❌ 需安装插件 | ❌ 需用户装 binary | 低 |
31+
| Codex CLI | ❌ 无原生 LSP | ❌ | 不适用 |
32+
| Cursor/Windsurf | 随扩展 | 扩展负责 | 中(依赖 IDE 生态) |
33+
| Aider | 无 LSP | 不适用 | 用 tree-sitter 替代 |
34+
35+
**结论**:OpenCode 的 LSP 自动安装程度已高于竞品,但**默认禁用**是最大门槛。
36+
37+
---
38+
39+
## 二、已阅读的文件及相关性
40+
41+
### 核心文件
42+
| 文件 | 为什么相关 |
43+
| --- | --- |
44+
| `src/lsp/lsp.ts:152-211` | LSP Service state 初始化,`if (!cfg.lsp)` 是默认禁用的核心判断。**本次主要修改点。** |
45+
| `src/config/config.ts:230` | `lsp: Schema.optional(ConfigLSP.Info)` — optional 导致默认 `undefined`。 |
46+
| `src/config/lsp.ts` | ConfigLSP schema 定义。`Info = Union([Boolean, Record(String, Entry)])`。 |
47+
| `src/lsp/server.ts` | 37 个服务器定义。10 个用 `Npm.which()`,12 个无自动安装。 |
48+
| `src/effect/runtime-flags.ts:21` | `disableLspDownload: bool("OPENCODE_DISABLE_LSP_DOWNLOAD")` 默认 false。 |
49+
| `src/util/which.ts` | `which()` 在 PATH + `Global.Path.bin` 中查找。 |
50+
| `packages/core/src/npm.ts:197-244` | `Npm.which()` 用 `@npmcli/arborist` 自动安装。**已修复 Issue #9404 的 Bun bug。** |
51+
52+
### 测试文件
53+
| 文件 | 为什么相关 |
54+
| --- | --- |
55+
| `test/lsp/index.test.ts:53-95` | "LSP is unset" 和 "lsp is true" 的对比测试。**需更新。** |
56+
| `test/lsp/lifecycle.test.ts:49-71` | hasClients 在 LSP unset/false/true 下的行为。**需更新。** |
57+
| `test/lsp/client.test.ts` | 诊断逻辑测试。不受影响。 |
58+
| `test/effect/runtime-flags.test.ts:133-145` | `disableLspDownload` 默认 false。不受影响。 |
59+
60+
### 外部调研
61+
| 来源 | 关键结论 |
62+
| --- | --- |
63+
| PR #23416 (Closed) | 官方拒绝默认启用 LSP |
64+
| PR #32876 (Open) | 文档明确 LSP 默认禁用 |
65+
| PR #18308 (Merged) | `@npmcli/arborist` 替换 Bun,**已在本地基线中** |
66+
| PR #14228 (Open) | 插件注册 LSP,未合并 |
67+
| Issue #23566/#27537 | 用户困惑 LSP 默认禁用 |
68+
| Issue #9404 (Closed) | Bun 自动安装 bug,已被 #18308 间接修复 |
69+
| ChatGPT 深度调研 | 确认无统一自包含 LSP 方案;OpenCode 自动安装程度高于竞品 |
70+
71+
---
72+
73+
## 三、必须保持的既有行为
74+
75+
1. **`"lsp": false` 显式禁用整个 LSP 子系统** — 用户可以通过设置 `false` 完全关闭 LSP。
76+
2. **`"lsp": true` 启用所有内置服务器** — 不改变。
77+
3. **`"lsp": { ... }` 对象配置自定义服务器** — 不改变。对象中 `"disabled": true` 仍可禁用单个服务器。
78+
4. **`disableLspDownload` flag** — 仍控制是否允许自动下载/安装。
79+
5. **LSP 按需启动** — 服务器只在 `touchFile` 时基于文件扩展名 spawn,不在启动时全部启动。
80+
6. **`[local-smark]` status() 检查** — write/edit/apply_patch 中空诊断时区分"LSP 未运行"的逻辑不变。
81+
7. **`hasClients` 检查 broken Set** — spawn 失败的 server 会被跳过。
82+
8. **LSP init() 是 fire-and-forget** — 不阻塞 opencode 启动。
83+
84+
---
85+
86+
## 四、推荐的最小实现方案
87+
88+
### 核心改动:默认启用 LSP(2 行)
89+
90+
**文件**:`packages/opencode/src/lsp/lsp.ts`
91+
92+
```ts
93+
// 行 157,从:
94+
if (!cfg.lsp) {
95+
// 改为:
96+
// [local-smark] 默认启用 LSP:未配置时视为 true,仅 false 显式禁用。
97+
// 官方 PR #23416 因"LSP 有意默认禁用"被关闭,但作为 fork 我们选择默认启用
98+
// 以降低使用门槛。LSP 按需启动(touchFile 时 spawn),不影响启动性能。
99+
if (cfg.lsp === false) {
100+
```
101+
102+
```ts
103+
// 行 166,从:
104+
if (cfg.lsp !== true) {
105+
// 改为:
106+
// [local-smark] cfg.lsp 为 undefined(默认启用)时跳过自定义配置遍历,
107+
// 防止 Object.entries(undefined) 报错。仅对象类型才进入自定义配置。
108+
if (cfg.lsp && cfg.lsp !== true) {
109+
```
110+
111+
### 行为变化矩阵
112+
113+
| `cfg.lsp` 值 | 改前行为 | 改后行为 | 变化 |
114+
| --- | --- | --- | --- |
115+
| `undefined`(未配置) | ❌ 禁用 | ✅ 启用所有内置 | **核心变化** |
116+
| `false` | ❌ 禁用 | ❌ 禁用 | 不变 |
117+
| `true` | ✅ 启用所有内置 | ✅ 启用所有内置 | 不变 |
118+
| `{ ... }` | ✅ 启用配置的 | ✅ 启用配置的 | 不变 |
119+
120+
### 为什么这个方案最符合现有设计
121+
122+
1. **最小改动**:2 行核心逻辑改动,不新增抽象、配置项或 API。
123+
2. **复用现有架构**:不改变 LSP Service interface、不改变 server spawn 逻辑、不改变诊断流程。
124+
3. **向后兼容**:`"lsp": false` 仍可禁用;`"lsp": true` 和 `"lsp": {...}` 不受影响。
125+
4. **按需启动**:LSP 服务器只在 `touchFile` 时 spawn(基于文件扩展名),默认启用不会在启动时全部 spawn 37 个服务器。`init()` 是 fire-and-forget。
126+
5. **安全网**:smark 已有的 `[local-smark]` status() 检查确保无 server 时不产生干扰诊断。
127+
128+
### 不做的(及原因)
129+
130+
| 不做的 | 原因 |
131+
| --- | --- |
132+
| 为 12 个无自动安装的服务器添加 Npm.which() | 大部分需要特定语言运行时(dart/ocaml/gleam/clojure/nix/haskell/julia),无法通过 npm 安装;工作量大且收益低 |
133+
| 改进 disabled 状态显示(区分原因) | 需改 Status schema + UI,范围大,与核心需求不直接相关 |
134+
| 回移 PR #14228(插件注册 LSP) | 6 commits,未合并,改动大,风险高 |
135+
| 添加 oxlint 的 Npm.which() | oxlint 的 LSP 模式需 `--lsp` 检查,逻辑复杂,可后续迭代 |
136+
137+
---
138+
139+
## 五、预计修改的文件
140+
141+
### 1. `packages/opencode/src/lsp/lsp.ts`(修改 2 行 + 2 行注释)
142+
143+
行 157:`if (!cfg.lsp)` → `if (cfg.lsp === false)` + 注释
144+
行 166:`if (cfg.lsp !== true)` → `if (cfg.lsp && cfg.lsp !== true)` + 注释
145+
146+
### 2. `packages/opencode/test/lsp/index.test.ts`(修改 ~5 行)
147+
148+
将 "when LSP is unset" 测试改为 "when LSP is false"(加 `config: { lsp: false }`):
149+
150+
```ts
151+
// 原(行 53):
152+
it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
153+
provideTmpdirInstance((dir) =>
154+
...
155+
expect(spy).toHaveBeenCalledTimes(0)
156+
...
157+
),
158+
)
159+
160+
// 改为:
161+
it.live("does not spawn builtin LSP for files inside instance when LSP is false", () =>
162+
provideTmpdirInstance(
163+
(dir) =>
164+
...
165+
expect(spy).toHaveBeenCalledTimes(0)
166+
...
167+
,
168+
{ config: { lsp: false } },
169+
),
170+
)
171+
```
172+
173+
添加默认启用测试:
174+
```ts
175+
it.live("spawns builtin LSP for files inside instance when LSP is unset (default enabled)", () =>
176+
provideTmpdirInstance((dir) =>
177+
LSP.Service.use((lsp) =>
178+
Effect.gen(function* () {
179+
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
180+
try {
181+
yield* lsp.hover({
182+
file: path.join(dir, "src", "inside.ts"),
183+
line: 0,
184+
character: 0,
185+
})
186+
expect(spy).toHaveBeenCalledTimes(1)
187+
} finally {
188+
spy.mockRestore()
189+
}
190+
}),
191+
),
192+
),
193+
)
194+
```
195+
196+
### 3. `packages/opencode/test/lsp/lifecycle.test.ts`(修改 ~5 行)
197+
198+
将 "when LSP is unset" 测试改为 "when LSP is false"(加 `config: { lsp: false }`):
199+
200+
```ts
201+
// 原(行 49):
202+
it.live("hasClients() returns false for .ts files in instance when LSP is unset", () =>
203+
provideTmpdirInstance((dir) =>
204+
...
205+
expect(result).toBe(false)
206+
...
207+
),
208+
)
209+
210+
// 改为:
211+
it.live("hasClients() returns false for .ts files in instance when LSP is false", () =>
212+
provideTmpdirInstance(
213+
(dir) =>
214+
...
215+
expect(result).toBe(false)
216+
...
217+
,
218+
{ config: { lsp: false } },
219+
),
220+
)
221+
```
222+
223+
添加默认启用测试:
224+
```ts
225+
it.live("hasClients() returns true for .ts files in instance when LSP is unset (default enabled)", () =>
226+
provideTmpdirInstance((dir) =>
227+
LSP.Service.use((lsp) =>
228+
Effect.gen(function* () {
229+
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
230+
expect(result).toBe(true)
231+
}),
232+
),
233+
),
234+
)
235+
```
236+
237+
---
238+
239+
## 六、正常路径、错误路径、并发/安全边界
240+
241+
### 正常路径(默认启用后)
242+
1. 用户安装 opencode,不配置 `lsp`。
243+
2. opencode 启动 → `init()` fire-and-forget → `InstanceState.get(state)` → `cfg.lsp` 为 `undefined` → `cfg.lsp === false` 为 false → 加载所有内置服务器到 `s.servers`。
244+
3. Agent 操作 `.ts` 文件 → `touchFile(file)` → `getClients(file)` → TypeScript server 匹配扩展名 → `server.spawn(root, ctx, flags)` → `which("typescript-language-server")` 或 `Npm.which()` 安装 → client 创建 → 诊断可用。
245+
246+
### 错误路径
247+
- **二进制未安装且下载被禁用**:`flags.disableLspDownload` 为 true → spawn 返回 undefined → server 加入 `broken` Set → `hasClients` 跳过 → lsp 工具抛 "No LSP server available" → write/edit 的 smark status() 检查显示 "LSP diagnostics unavailable"。
248+
- **二进制未安装但允许下载**:`Npm.which()` 触发安装 → 安装成功则正常 → 安装失败则 `Npm.which()` 返回 none → spawn 返回 undefined → 同上。
249+
- **项目无 typescript 依赖**:`Module.resolve("typescript/lib/tsserver.js")` 返回 undefined → spawn 返回 undefined → 同上。
250+
- **`cfg.lsp` 为 `false`**:`cfg.lsp === false` 为 true → 禁用 → servers 为空 → 所有 LSP 操作返回空/抛错。
251+
252+
### 并发
253+
- **多个 touchFile 并发**:`getClients` 有 `spawning` Map 去重,同一 server+root 的 spawn 只执行一次。
254+
- **默认启用不增加并发压力**:服务器仍按需 spawn,不会同时启动 37 个。
255+
256+
### 安全边界
257+
- **资源控制**:`disableLspDownload` 仍控制下载行为。用户可设 `"lsp": false` 完全禁用。
258+
- **向后兼容**:现有配置(`false`/`true`/`{...}`)行为不变。
259+
260+
---
261+
262+
## 七、行为级测试计划
263+
264+
### 先写的测试
265+
1. **默认启用验证**:无 config 时 hasClients(.ts) 返回 true。
266+
2. **显式禁用验证**:`config: { lsp: false }` 时 hasClients(.ts) 返回 false,spawn 不被调用。
267+
3. **显式启用验证**:`config: { lsp: true }` 时 spawn 被调用(已有测试,不变)。
268+
4. **对象配置验证**:`config: { lsp: { eslint: { disabled: true } } }` 时内置 TypeScript 仍可用(已有测试,不变)。
269+
5. **undefined 安全性**:无 config 时 `Object.entries` 不报错(隐含在测试 1 中)。
270+
271+
### 当前实现下会暴露的缺口
272+
- 当前 "when LSP is unset" 测试期望 hasClients=false / spawn=0,改后期望反转。
273+
- 无默认启用的测试覆盖。
274+
275+
### 实现后验证
276+
- `bun test test/lsp/` 全部通过
277+
- `bun typecheck` 通过
278+
279+
---
280+
281+
## 八、建议运行的验证命令
282+
283+
```bash
284+
cd packages/opencode && bun typecheck
285+
cd packages/opencode && bun test test/lsp/
286+
cd packages/opencode && bun test test/tool/lsp.test.ts
287+
```
288+
289+
---
290+
291+
## 九、预估 git 文件数、增删行数
292+
293+
| 文件 | 新增 | 删除 | 说明 |
294+
| --- | --- | --- | --- |
295+
| `src/lsp/lsp.ts` | 4 | 2 | 2 行逻辑 + 2 行注释 |
296+
| `test/lsp/index.test.ts` | 12 | 3 | 改 1 测试 + 加 1 测试 |
297+
| `test/lsp/lifecycle.test.ts` | 12 | 3 | 改 1 测试 + 加 1 测试 |
298+
| **合计** | ~28 | ~8 | 3 个文件,净增 ~20 行 |
299+
300+
无生成文件、无迁移、无新文档(本方案文档除外)。
301+
302+
---
303+
304+
## 十、真实风险与开放问题
305+
306+
### 风险
307+
1. **资源消耗**:默认启用后,agent 操作文件时会触发 LSP spawn。但按需启动(基于扩展名),不会全部启动。首次操作可能触发 `Npm.which()` 安装(几秒到几十秒),之后有缓存。**低风险**。
308+
2. **诊断干扰**:默认启用后,write/edit/apply_patch 会显示诊断。但 smark 已有 status() 检查(无 server 时不显示错误),不会产生干扰。**低风险**。
309+
3. **向后兼容**:未配置 `lsp` 的用户从"禁用"变为"启用"。这是**预期行为变化**,不是回归。用户可设 `"lsp": false` 恢复。**低风险**。
310+
4. **与官方分叉**:官方明确拒绝默认启用(PR #23416)。作为 fork 我们选择不同方向。后续 merge upstream 时需保留此改动。**已知风险,可接受**。
311+
312+
### 开放问题
313+
1. **是否需要文档更新**:opencode.ai/docs/lsp 文档暗示 LSP 自动安装,但实际默认禁用。默认启用后文档与行为一致。如需更新文档,是 separate PR。
314+
2. **是否需要 UI 提示**:默认启用后,用户不再需要知道 `"lsp": true`。但如果 LSP 服务器安装失败,用户仍需知道原因。改进状态显示是后续迭代。
315+
3. **oxlint 自动安装**:oxlint 可通过 npm 安装但当前未用 `Npm.which()`。可作为后续迭代。
316+
317+
---
318+
319+
## 十一、推荐方案摘要
320+
321+
**1 项改动,3 个文件,净增约 20 行:**
322+
323+
**默认启用 LSP**:将 `lsp.ts` 中 `if (!cfg.lsp)` 改为 `if (cfg.lsp === false)`,同时将 `if (cfg.lsp !== true)` 改为 `if (cfg.lsp && cfg.lsp !== true)` 防止 undefined 时 `Object.entries` 报错。
324+
325+
**效果**:用户不再需要在 `opencode.json` 中配置 `"lsp": true`。opencode 安装后即默认启用所有内置 LSP 服务器(按需启动)。已有的自动安装机制(`Npm.which()` / `go install` / `gem install` / `dotnet tool install` / GitHub Release 下载)会自动为 TypeScript、Python、Go、Ruby、C#、Rust、C/C++、Java、Kotlin、YAML、Lua、PHP、Bash、Svelte、Astro、Vue、ESLint、Biome 等语言安装 LSP 服务器。
326+
327+
**不做的**:为 12 个无自动安装的服务器添加安装逻辑(大部分需特定语言运行时)、改进 disabled 状态显示、回移 PR #14228(插件注册 LSP)、oxlint 自动安装。

0 commit comments

Comments
 (0)