diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx index ac3a3999798..f7cc8d3dde8 100644 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -16,7 +16,9 @@ export function SessionLspIndicator() { const tooltipContent = createMemo(() => { const lsp = sync.data.lsp ?? [] if (lsp.length === 0) return "No LSP servers" - return lsp.map((s) => s.name).join(", ") + const servers = lsp.map((s) => s.name).join(", ") + const diagStatus = sync.data.lsp_diagnostics ? "enabled" : "disabled" + return `${servers} • Diagnostics: ${diagStatus}` }) return ( diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..ceeb38b11bd 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -52,6 +52,7 @@ type State = { [name: string]: McpStatus } lsp: LspStatus[] + lsp_diagnostics: boolean vcs: VcsInfo | undefined limit: number message: { @@ -98,6 +99,7 @@ function createGlobalSync() { permission: {}, mcp: {}, lsp: [], + lsp_diagnostics: true, vcs: undefined, limit: 5, message: {}, @@ -172,6 +174,7 @@ function createGlobalSync() { loadSessions(directory), sdk.mcp.status().then((x) => setStore("mcp", x.data!)), sdk.lsp.status().then((x) => setStore("lsp", x.data!)), + sdk.lsp.diagnostics.status().then((x) => setStore("lsp_diagnostics", x.data?.enabled ?? true)), sdk.vcs.get().then((x) => setStore("vcs", x.data)), sdk.permission.list().then((x) => { const grouped: Record = {} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d3d8ef387cb..baf5dd6d0ed 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -434,6 +434,24 @@ export default function Page() { slash: "mcp", onSelect: () => dialog.show(() => ), }, + { + id: "lsp.diagnostics.toggle", + title: "Toggle LSP Diagnostics", + description: "Toggle LSP diagnostics to model", + category: "LSP", + slash: "lsp-diagnostics", + onSelect: async () => { + await sdk.client.lsp.diagnostics.toggle() + const status = await sdk.client.lsp.diagnostics.status() + if (status.data) { + sync.set("lsp_diagnostics", status.data.enabled) + showToast({ + title: "LSP Diagnostics", + description: `Diagnostics ${status.data.enabled ? "enabled" : "disabled"}`, + }) + } + }, + }, { id: "agent.cycle", title: "Cycle agent", diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2af5b21152c..ba3de9d25ad 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -365,6 +365,23 @@ function App() { dialog.replace(() => ) }, }, + { + title: "Toggle LSP Diagnostics", + value: "lsp.diagnostics.toggle", + category: "Agent", + onSelect: async () => { + await sdk.client.lsp.diagnostics.toggle() + const status = await sdk.client.lsp.diagnostics.status() + if (status.data) { + sync.set("lsp_diagnostics", status.data.enabled) + toast.show({ + variant: "info", + message: `LSP diagnostics ${status.data.enabled ? "enabled" : "disabled"}`, + duration: 2000, + }) + } + }, + }, { title: "Agent cycle", value: "agent.cycle", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 57eac9544e6..d0f0a39cd6f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -425,6 +425,11 @@ export function Autocomplete(props: { description: "toggle MCPs", onSelect: () => command.trigger("mcp.list"), }, + { + display: "/diagnostics", + description: "toggle LSP Diagnostics", + onSelect: () => command.trigger("lsp.diagnostics.toggle"), + }, { display: "/theme", description: "toggle theme", diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 8a14d8b2e77..1b06cfb860a 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -60,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ [messageID: string]: Part[] } lsp: LspStatus[] + lsp_diagnostics: boolean mcp: { [key: string]: McpStatus } @@ -90,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ message: {}, part: {}, lsp: [], + lsp_diagnostics: true, mcp: {}, mcp_resource: {}, formatter: [], @@ -300,6 +302,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ...(args.continue ? [] : [sessionListPromise]), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), + sdk.client.lsp.diagnostics.status().then((x) => setStore("lsp_diagnostics", x.data?.enabled ?? true)), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), sdk.client.experimental.resource.list().then((x) => setStore("mcp_resource", reconcile(x.data ?? {}))), sdk.client.formatter.status().then((x) => setStore("formatter", reconcile(x.data!))), diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 98b8cd6d349..3e454d47e24 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -198,6 +198,9 @@ export function Sidebar(props: { sessionID: string }) { )} + + Diagnostics: {sync.data.lsp_diagnostics ? "Enabled" : "Disabled"} + 0 && todo().some((t) => t.status !== "completed")}> diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 0fd3b69dfcd..5385b62f458 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -89,6 +89,7 @@ export namespace LSP { servers, clients, spawning: new Map>(), + diagnosticsEnabled: true, } } @@ -136,6 +137,7 @@ export namespace LSP { servers, clients, spawning: new Map>(), + diagnosticsEnabled: true, } }, async (state) => { @@ -466,6 +468,25 @@ export namespace LSP { return Promise.all(tasks) } + export const DiagnosticsStatus = z + .object({ + enabled: z.boolean(), + }) + .meta({ ref: "LSPDiagnosticsStatus" }) + export type DiagnosticsStatus = z.infer + + export async function toggleDiagnostics(): Promise { + const s = await state() + s.diagnosticsEnabled = !(s.diagnosticsEnabled ?? true) + log.info("toggled LSP diagnostics", { enabled: s.diagnosticsEnabled }) + return s.diagnosticsEnabled + } + + export async function diagnosticsStatus(): Promise { + const s = await state() + return { enabled: s.diagnosticsEnabled ?? true } + } + export namespace Diagnostic { export function pretty(diagnostic: LSPClient.Diagnostic) { const severityMap = { diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 615d9272866..8b507346893 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -51,6 +51,7 @@ import { PermissionNext } from "@/permission/next" import { Installation } from "@/installation" import { MDNS } from "./mdns" import { Worktree } from "../worktree" +import ignore from "ignore" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false diff --git a/packages/opencode/src/server/tui.ts b/packages/opencode/src/server/tui.ts index 42821ad9e81..d4d1a2a5440 100644 --- a/packages/opencode/src/server/tui.ts +++ b/packages/opencode/src/server/tui.ts @@ -2,6 +2,7 @@ import { Hono, type Context } from "hono" import { describeRoute, resolver, validator } from "hono-openapi" import { z } from "zod" import { AsyncQueue } from "../util/queue" +import { LSP } from "@/lsp" const TuiRequest = z.object({ path: z.string(), @@ -69,3 +70,46 @@ export const TuiRoute = new Hono() return c.json(true) }, ) + .get( + "/lsp/diagnostics/status", + describeRoute({ + summary: "Get LSP diagnostics toggle status", + description: "Returns the current state of LSP diagnostics toggle", + operationId: "lsp.diagnostics.status", + responses: { + 200: { + description: "LSP diagnostics toggle status", + content: { + "application/json": { + schema: resolver(LSP.DiagnosticsStatus), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await LSP.diagnosticsStatus()) + }, + ) + .post( + "/lsp/diagnostics/toggle", + describeRoute({ + summary: "Toggle LSP diagnostics", + description: "Toggle whether LSP diagnostics are sent to the model", + operationId: "lsp.diagnostics.toggle", + responses: { + 200: { + description: "Updated diagnostics status", + content: { + "application/json": { + schema: resolver(LSP.DiagnosticsStatus), + }, + }, + }, + }, + }), + async (c) => { + const enabled = await LSP.toggleDiagnostics() + return c.json({ enabled }) + }, + ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 787282ecd04..19645c38d16 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -131,7 +131,7 @@ export const EditTool = Tool.define("edit", { let output = "" await LSP.touchFile(filePath, true) - const diagnostics = await LSP.diagnostics() + const diagnostics = ((await LSP.diagnosticsStatus()).enabled) ? await LSP.diagnostics() : {} const normalizedFilePath = Filesystem.normalizePath(filePath) const issues = diagnostics[normalizedFilePath] ?? [] const errors = issues.filter((item) => item.severity === 1) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index a0ca6b14f7c..9f075b6b330 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -53,7 +53,7 @@ export const WriteTool = Tool.define("write", { let output = "" await LSP.touchFile(filepath, true) - const diagnostics = await LSP.diagnostics() + const diagnostics = ((await LSP.diagnosticsStatus()).enabled) ? await LSP.diagnostics() : {} const normalizedFilepath = Filesystem.normalizePath(filepath) let projectDiagnosticsCount = 0 for (const [file, issues] of Object.entries(diagnostics)) { diff --git a/packages/opencode/test/lsp/diagnostics.test.ts b/packages/opencode/test/lsp/diagnostics.test.ts new file mode 100644 index 00000000000..fb808e71d06 --- /dev/null +++ b/packages/opencode/test/lsp/diagnostics.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import { LSP } from "../../src/lsp" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" +import { WriteTool } from "../../src/tool/write" +import { EditTool } from "../../src/tool/edit" + +const ctx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +describe("LSP Diagnostics Toggle Integration", () => { + beforeEach(async () => { + await Log.init({ print: false }) + }) + + test("Write tool respects diagnostics toggle when disabled", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await LSP.init() + + await LSP.toggleDiagnostics() + expect((await LSP.diagnosticsStatus()).enabled).toBe(false) + + const write = await WriteTool.init() + const testFile = path.join(tmp.path, "test.ts") + + const result = await write.execute( + { + filePath: testFile, + content: `const x: number = "hello";\nconst missing = undefinedVar;`, + }, + ctx, + ) + + expect(result.output).not.toContain("") + expect(result.output).not.toContain("ERROR") + }, + }) + }) + + test("Write tool shows diagnostics when enabled", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create a tsconfig to enable TypeScript LSP + await Bun.write( + path.join(dir, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + target: "ES2020", + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await LSP.init() + + const status = await LSP.diagnosticsStatus() + expect(status.enabled).toBe(true) + + const write = await WriteTool.init() + const testFile = path.join(tmp.path, "test.ts") + + const result = await write.execute( + { + filePath: testFile, + content: `const x: number = "hello";\nconst missing = undefinedVar;`, + }, + ctx, + ) + + // Wait a bit for LSP to process + await new Promise((r) => setTimeout(r, 500)) + + // Note: Actual diagnostics may not appear if LSP isn't running, + // but we're testing the filtering logic + // The tool should at least attempt to fetch diagnostics + expect(result).toBeDefined() + }, + }) + }) + + test("Edit tool respects diagnostics toggle when disabled", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await LSP.init() + const testFile = path.join(tmp.path, "test.ts") + await Bun.write(testFile, `const x = 1;`) + + // Read the file first (required by Edit tool) + const { ReadTool } = await import("../../src/tool/read") + const read = await ReadTool.init() + await read.execute({ filePath: testFile }, ctx) + + await LSP.toggleDiagnostics() + expect((await LSP.diagnosticsStatus()).enabled).toBe(false) + + const edit = await EditTool.init() + const result = await edit.execute( + { + filePath: testFile, + oldString: "const x = 1;", + newString: 'const x: number = "hello";', + }, + ctx, + ) + + expect(result.output).not.toContain("") + expect(result.output).not.toContain("ERROR") + }, + }) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a26cefb176f..c4500d79db3 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -35,6 +35,8 @@ import type { GlobalEventResponses, GlobalHealthResponses, InstanceDisposeResponses, + LspDiagnosticsStatusResponses, + LspDiagnosticsToggleResponses, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -2482,6 +2484,46 @@ export class Experimental extends HeyApiClient { resource = new Resource({ client: this.client }) } +export class Diagnostics extends HeyApiClient { + /** + * Get LSP diagnostics toggle status + * + * Returns the current state of LSP diagnostics toggle + */ + public status( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/tui/control/lsp/diagnostics/status", + ...options, + ...params, + }) + } + + /** + * Toggle LSP diagnostics + * + * Toggle whether LSP diagnostics are sent to the model + */ + public toggle( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).post({ + url: "/tui/control/lsp/diagnostics/toggle", + ...options, + ...params, + }) + } +} + export class Lsp extends HeyApiClient { /** * Get LSP status @@ -2501,6 +2543,8 @@ export class Lsp extends HeyApiClient { ...params, }) } + + diagnostics = new Diagnostics({ client: this.client }) } export class Formatter extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 97a695162ed..d74d6231948 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1986,6 +1986,10 @@ export type FormatterStatus = { enabled: boolean } +export type LspDiagnosticsStatus = { + enabled: boolean +} + export type OAuth = { type: "oauth" refresh: string @@ -4546,6 +4550,42 @@ export type TuiControlResponseResponses = { export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses] +export type LspDiagnosticsStatusData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/control/lsp/diagnostics/status" +} + +export type LspDiagnosticsStatusResponses = { + /** + * LSP diagnostics toggle status + */ + 200: LspDiagnosticsStatus +} + +export type LspDiagnosticsStatusResponse = LspDiagnosticsStatusResponses[keyof LspDiagnosticsStatusResponses] + +export type LspDiagnosticsToggleData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/tui/control/lsp/diagnostics/toggle" +} + +export type LspDiagnosticsToggleResponses = { + /** + * Updated diagnostics status + */ + 200: LspDiagnosticsStatus +} + +export type LspDiagnosticsToggleResponse = LspDiagnosticsToggleResponses[keyof LspDiagnosticsToggleResponses] + export type AuthSetData = { body?: Auth path: {