From 7cd1c5c0cbba974decb13d4eedd20f472759e71c Mon Sep 17 00:00:00 2001 From: KKdev15 Date: Tue, 17 Mar 2026 01:43:21 +0000 Subject: [PATCH] feat(opencode): add per-LSP min_severity config option Add configurable minimum severity level for LSP diagnostics on a per-LSP basis. This allows users to see warnings, info, and hints from specific LSP servers, not just errors. Example config: { "lsp": { "markdownlint": { "command": ["markdownlint-lsp"], "extensions": [".md"], "min_severity": 2 } } } Closes #17869 --- .../src/cli/cmd/tui/routes/session/index.tsx | 21 +++- packages/opencode/src/config/config.ts | 14 ++- packages/opencode/src/lsp/index.ts | 35 +++++- packages/opencode/src/tool/apply_patch.ts | 3 +- packages/opencode/src/tool/edit.ts | 3 +- packages/opencode/src/tool/write.ts | 3 +- .../opencode/test/lsp/min-severity.test.ts | 118 ++++++++++++++++++ 7 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 packages/opencode/test/lsp/min-severity.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7456742cdf36..94c3b3354ba2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -2228,10 +2228,29 @@ function Skill(props: ToolProps) { function Diagnostics(props: { diagnostics?: Record[]>; filePath: string }) { const { theme } = useTheme() + const sync = useSync() const errors = createMemo(() => { const normalized = Filesystem.normalizePath(props.filePath) const arr = props.diagnostics?.[normalized] ?? [] - return arr.filter((x) => x.severity === 1).slice(0, 3) + const ext = path.extname(props.filePath) + let min = 1 + // Look up min_severity from LSP config by matching file extension + const lspConfig = sync.data.config.lsp + if (lspConfig && typeof lspConfig === "object") { + for (const [id, cfg] of Object.entries(lspConfig)) { + if (!cfg || "disabled" in cfg) continue + const lsp = sync.data.lsp.find((l) => l.id === id) + if (lsp && "min_severity" in cfg && typeof cfg.min_severity === "number") { + // Check if this LSP handles this file extension + const exts = cfg.extensions + if (!exts || exts.includes(ext)) { + min = cfg.min_severity + break + } + } + } + } + return arr.filter((x) => (x.severity ?? 1) <= min).slice(0, 3) }) return ( diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 27ba4e186712..84b6ee51e136 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1154,11 +1154,18 @@ export namespace Config { disabled: z.literal(true), }), z.object({ - command: z.array(z.string()), + command: z.array(z.string()).optional(), extensions: z.array(z.string()).optional(), disabled: z.boolean().optional(), env: z.record(z.string(), z.string()).optional(), initialization: z.record(z.string(), z.any()).optional(), + min_severity: z + .number() + .int() + .min(1) + .max(4) + .optional() + .describe("Minimum diagnostic severity to show (1=Error, 2=Warning, 3=Info, 4=Hint). Default: 1"), }), ]), ), @@ -1173,11 +1180,12 @@ export namespace Config { return Object.entries(data).every(([id, config]) => { if (config.disabled) return true if (serverIds.has(id)) return true - return Boolean(config.extensions) + return Boolean(config.extensions) || Boolean(config.min_severity) }) }, { - error: "For custom LSP servers, 'extensions' array is required.", + error: + "For custom LSP servers, 'extensions' array is required (unless only setting min_severity for a built-in server).", }, ), instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"), diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 6ea7554c0968..b8326a7ce1f2 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -105,6 +105,16 @@ export namespace LSP { delete servers[name] continue } + // If no command provided, just update config for existing server + if (!item.command) { + if (existing) { + servers[name] = { + ...existing, + extensions: item.extensions ?? existing.extensions, + } + } + continue + } servers[name] = { ...existing, id: name, @@ -112,7 +122,7 @@ export namespace LSP { extensions: item.extensions ?? existing?.extensions ?? [], spawn: async (root) => { return { - process: spawn(item.command[0], item.command.slice(1), { + process: spawn(item.command![0], item.command!.slice(1), { cwd: root, windowsHide: true, env: { @@ -482,5 +492,28 @@ export namespace LSP { return `${severity} [${line}:${col}] ${diagnostic.message}` } + + export function filter(items: LSPClient.Diagnostic[], min: number) { + return items.filter((d) => (d.severity ?? 1) <= min) + } + } + + export async function minSeverity(file: string) { + const cfg = await Config.get() + if (!cfg.lsp || typeof cfg.lsp !== "object") return 1 + + const ext = path.extname(file) + const s = await state() + + // Find servers that handle this file extension + for (const server of Object.values(s.servers)) { + if (server.extensions.length && !server.extensions.includes(ext)) continue + const item = cfg.lsp[server.id] + if (item && !("disabled" in item && item.disabled) && "min_severity" in item && item.min_severity) { + return item.min_severity + } + } + + return 1 } } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 06293b6eba6e..6c453dafb143 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -259,7 +259,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { const target = change.movePath ?? change.filePath const normalized = Filesystem.normalizePath(target) const issues = diagnostics[normalized] ?? [] - const errors = issues.filter((item) => item.severity === 1) + const min = await LSP.minSeverity(target) + const errors = LSP.Diagnostic.filter(issues, min) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 1a7614fc17fb..254228779992 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -147,7 +147,8 @@ export const EditTool = Tool.define("edit", { const diagnostics = await LSP.diagnostics() const normalizedFilePath = Filesystem.normalizePath(filePath) const issues = diagnostics[normalizedFilePath] ?? [] - const errors = issues.filter((item) => item.severity === 1) + const min = await LSP.minSeverity(filePath) + const errors = LSP.Diagnostic.filter(issues, min) if (errors.length > 0) { const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 83474a543ca1..881969514b93 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -57,7 +57,8 @@ export const WriteTool = Tool.define("write", { const normalizedFilepath = Filesystem.normalizePath(filepath) let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { - const errors = issues.filter((item) => item.severity === 1) + const min = await LSP.minSeverity(file) + const errors = LSP.Diagnostic.filter(issues, min) if (errors.length === 0) continue const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) const suffix = diff --git a/packages/opencode/test/lsp/min-severity.test.ts b/packages/opencode/test/lsp/min-severity.test.ts new file mode 100644 index 000000000000..031dc2678641 --- /dev/null +++ b/packages/opencode/test/lsp/min-severity.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "bun:test" +import { Config } from "../../src/config/config" +import { LSP } from "../../src/lsp/index" + +describe("LSP min_severity", () => { + describe("Config schema", () => { + it("accepts min_severity per-LSP", () => { + const testConfig = { + lsp: { + typescript: { + min_severity: 2 + } + } + } + + const result = Config.Info.safeParse(testConfig) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.lsp).toBeDefined() + expect((result.data.lsp as any).typescript.min_severity).toBe(2) + } + }) + + it("accepts min_severity with command and extensions", () => { + const testConfig = { + lsp: { + markdownlint: { + command: ["markdownlint-lsp"], + extensions: [".md"], + min_severity: 3 + } + } + } + + const result = Config.Info.safeParse(testConfig) + expect(result.success).toBe(true) + }) + + it("rejects min_severity > 4", () => { + const testConfig = { + lsp: { + typescript: { + min_severity: 5 + } + } + } + + const result = Config.Info.safeParse(testConfig) + expect(result.success).toBe(false) + }) + + it("rejects min_severity < 1", () => { + const testConfig = { + lsp: { + typescript: { + min_severity: 0 + } + } + } + + const result = Config.Info.safeParse(testConfig) + expect(result.success).toBe(false) + }) + + it("accepts non-integer min_severity as invalid", () => { + const testConfig = { + lsp: { + typescript: { + min_severity: 1.5 + } + } + } + + const result = Config.Info.safeParse(testConfig) + expect(result.success).toBe(false) + }) + }) + + describe("LSP.Diagnostic.filter", () => { + const diagnostics = [ + { severity: 1, message: "Error", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, + { severity: 2, message: "Warning", range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } } }, + { severity: 3, message: "Info", range: { start: { line: 2, character: 0 }, end: { line: 2, character: 0 } } }, + { severity: 4, message: "Hint", range: { start: { line: 3, character: 0 }, end: { line: 3, character: 0 } } }, + ] as any + + it("returns only errors when min=1", () => { + const filtered = LSP.Diagnostic.filter(diagnostics, 1) + expect(filtered.length).toBe(1) + expect(filtered[0].message).toBe("Error") + }) + + it("returns errors and warnings when min=2", () => { + const filtered = LSP.Diagnostic.filter(diagnostics, 2) + expect(filtered.length).toBe(2) + expect(filtered.map(d => d.message)).toEqual(["Error", "Warning"]) + }) + + it("returns errors, warnings, and info when min=3", () => { + const filtered = LSP.Diagnostic.filter(diagnostics, 3) + expect(filtered.length).toBe(3) + }) + + it("returns all diagnostics when min=4", () => { + const filtered = LSP.Diagnostic.filter(diagnostics, 4) + expect(filtered.length).toBe(4) + }) + + it("handles diagnostics without severity (defaults to 1)", () => { + const noSeverity = [ + { message: "No severity", range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } }, + ] as any + + const filtered = LSP.Diagnostic.filter(noSeverity, 1) + expect(filtered.length).toBe(1) + }) + }) +})