Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/app/src/components/session-lsp-indicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/global-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ type State = {
[name: string]: McpStatus
}
lsp: LspStatus[]
lsp_diagnostics: boolean
vcs: VcsInfo | undefined
limit: number
message: {
Expand Down Expand Up @@ -98,6 +99,7 @@ function createGlobalSync() {
permission: {},
mcp: {},
lsp: [],
lsp_diagnostics: true,
vcs: undefined,
limit: 5,
message: {},
Expand Down Expand Up @@ -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<string, PermissionRequest[]> = {}
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,24 @@ export default function Page() {
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{
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",
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,23 @@ function App() {
dialog.replace(() => <DialogMcp />)
},
},
{
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[messageID: string]: Part[]
}
lsp: LspStatus[]
lsp_diagnostics: boolean
mcp: {
[key: string]: McpStatus
}
Expand Down Expand Up @@ -90,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
message: {},
part: {},
lsp: [],
lsp_diagnostics: true,
mcp: {},
mcp_resource: {},
formatter: [],
Expand Down Expand Up @@ -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!))),
Expand Down
3 changes: 3 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,9 @@ export function Sidebar(props: { sessionID: string }) {
</box>
)}
</For>
<text fg={theme.textMuted}>
Diagnostics: {sync.data.lsp_diagnostics ? "Enabled" : "Disabled"}
</text>
</Show>
</box>
<Show when={todo().length > 0 && todo().some((t) => t.status !== "completed")}>
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export namespace LSP {
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
diagnosticsEnabled: true,
}
}

Expand Down Expand Up @@ -136,6 +137,7 @@ export namespace LSP {
servers,
clients,
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
diagnosticsEnabled: true,
}
},
async (state) => {
Expand Down Expand Up @@ -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<typeof DiagnosticsStatus>

export async function toggleDiagnostics(): Promise<boolean> {
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<DiagnosticsStatus> {
const s = await state()
return { enabled: s.diagnosticsEnabled ?? true }
}

export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/src/server/tui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 })
},
)
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
131 changes: 131 additions & 0 deletions packages/opencode/test/lsp/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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("<file_diagnostics>")
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("<file_diagnostics>")
expect(result.output).not.toContain("ERROR")
},
})
})
})
Loading