Skip to content

Commit fef0d7d

Browse files
committed
fix(opencode): make /lsps actionable and add LSP kill endpoints
1 parent 41612b3 commit fef0d7d

9 files changed

Lines changed: 450 additions & 24 deletions

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts"
3232
import { useDialog } from "@tui/ui/dialog"
3333
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
3434
import { DialogAlert } from "../../ui/dialog-alert"
35+
import { DialogSelect } from "../../ui/dialog-select"
3536
import { useToast } from "../../ui/toast"
3637
import { useKV } from "../../context/kv"
3738
import { useTextareaKeybindings } from "../textarea-keybindings"
@@ -398,6 +399,19 @@ export function Prompt(props: PromptProps) {
398399
))
399400
},
400401
},
402+
{
403+
title: "List LSP servers",
404+
value: "lsp.list",
405+
category: "System",
406+
description: "Use /lsps kill <name> to stop one",
407+
slash: {
408+
name: "lsps",
409+
},
410+
onSelect: async (dialog) => {
411+
dialog.clear()
412+
await runLsps("/lsps")
413+
},
414+
},
401415
]
402416
})
403417

@@ -537,6 +551,89 @@ export function Prompt(props: PromptProps) {
537551
)
538552
}
539553

554+
async function showLsps() {
555+
const result = await sdk.client.lsp.status().catch(() => undefined)
556+
if (!result || result.error) {
557+
toast.show({ message: "Failed to list LSP servers", variant: "error" })
558+
return
559+
}
560+
561+
const data = result.data ?? []
562+
if (data.length === 0) {
563+
toast.show({ message: "No LSP servers running", variant: "warning" })
564+
return
565+
}
566+
567+
dialog.replace(() => (
568+
<DialogSelect
569+
title="LSP Servers"
570+
options={data.map((item) => ({
571+
title: item.name,
572+
value: item.name,
573+
description: `/lsps kill ${item.name}`,
574+
footer: item.root || ".",
575+
}))}
576+
onSelect={async (option) => {
577+
await runLsps(`/lsps kill ${option.value}`)
578+
await showLsps()
579+
}}
580+
/>
581+
))
582+
}
583+
584+
async function runLsps(input: string) {
585+
const rest = input.replace(/^\/lsps/, "").trim()
586+
587+
if (!rest) {
588+
await showLsps()
589+
return true
590+
}
591+
592+
if (rest === "kill") {
593+
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
594+
return true
595+
}
596+
597+
if (rest === "killall") {
598+
const result = await sdk.client.lsp.killAll().catch(() => undefined)
599+
if (!result || result.error) {
600+
toast.show({ message: "Failed to kill LSP servers", variant: "error" })
601+
return true
602+
}
603+
if (!result.data) {
604+
toast.show({ message: "No LSP servers running", variant: "warning" })
605+
return true
606+
}
607+
toast.show({ message: "Killed all LSP servers", variant: "success" })
608+
return true
609+
}
610+
611+
if (!rest.startsWith("kill ")) {
612+
toast.show({ message: "Unknown /lsps command", variant: "warning" })
613+
return true
614+
}
615+
616+
const name = rest.slice("kill".length).trim()
617+
if (!name) {
618+
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
619+
return true
620+
}
621+
622+
const result = await sdk.client.lsp.kill({ name }).catch(() => undefined)
623+
if (!result || result.error) {
624+
toast.show({ message: `Failed to kill LSP server ${name}`, variant: "error" })
625+
return true
626+
}
627+
628+
if (!result.data) {
629+
toast.show({ message: `No running LSP server named ${name}`, variant: "warning" })
630+
return true
631+
}
632+
633+
toast.show({ message: `Killed LSP server ${name}`, variant: "success" })
634+
return true
635+
}
636+
540637
command.register(() => [
541638
{
542639
title: "Stash prompt",
@@ -601,6 +698,19 @@ export function Prompt(props: PromptProps) {
601698
exit()
602699
return
603700
}
701+
702+
if (store.mode === "normal" && trimmed.match(/^\/lsps(?:\s|$)/)) {
703+
await runLsps(trimmed)
704+
input.extmarks.clear()
705+
setStore("prompt", {
706+
input: "",
707+
parts: [],
708+
})
709+
setStore("extmarkToPartIndex", new Map())
710+
input.clear()
711+
return
712+
}
713+
604714
const selectedModel = local.model.current()
605715
if (!selectedModel) {
606716
promptModelWarning()
@@ -610,7 +720,7 @@ export function Prompt(props: PromptProps) {
610720
let sessionID = props.sessionID
611721
if (sessionID == null) {
612722
const res = await sdk.client.session.create({
613-
workspaceID: props.workspaceID,
723+
workspace: props.workspaceID,
614724
})
615725

616726
if (res.error) {

packages/opencode/src/lsp/index.ts

Lines changed: 112 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,29 @@ export namespace LSP {
130130
}
131131
}
132132

133+
const keyFor = (root: string, serverID: string) => `${root}\u0000${serverID}`
134+
135+
const keyMatchesServer = (key: string, serverID: string) => {
136+
const split = key.lastIndexOf("\u0000")
137+
if (split === -1) return false
138+
return key.slice(split + 1) === serverID
139+
}
140+
133141
type LocInput = { file: string; line: number; character: number }
134142

135143
interface State {
136144
clients: LSPClient.Info[]
137145
servers: Record<string, LSPServer.Info>
138146
broken: Set<string>
139147
spawning: Map<string, Promise<LSPClient.Info | undefined>>
148+
generation: Map<string, number>
140149
}
141150

142151
export interface Interface {
143152
readonly init: () => Effect.Effect<void>
144153
readonly status: () => Effect.Effect<Status[]>
154+
readonly kill: (name: string) => Effect.Effect<boolean>
155+
readonly killAll: () => Effect.Effect<boolean>
145156
readonly hasClients: (file: string) => Effect.Effect<boolean>
146157
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
147158
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
@@ -212,6 +223,7 @@ export namespace LSP {
212223
servers,
213224
broken: new Set(),
214225
spawning: new Map(),
226+
generation: new Map(),
215227
}
216228

217229
yield* Effect.addFinalizer(() =>
@@ -232,37 +244,47 @@ export namespace LSP {
232244
const result: LSPClient.Info[] = []
233245

234246
async function schedule(server: LSPServer.Info, root: string, key: string) {
247+
const generation = s.generation.get(key) ?? 0
235248
const handle = await server
236249
.spawn(root)
237250
.then((value) => {
238-
if (!value) s.broken.add(key)
251+
if (!value && (s.generation.get(key) ?? 0) === generation) s.broken.add(key)
239252
return value
240253
})
241254
.catch((err) => {
242-
s.broken.add(key)
255+
if ((s.generation.get(key) ?? 0) === generation) s.broken.add(key)
243256
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
244257
return undefined
245258
})
246259

247260
if (!handle) return undefined
261+
if ((s.generation.get(key) ?? 0) !== generation) {
262+
handle.process.kill()
263+
return undefined
264+
}
248265
log.info("spawned lsp server", { serverID: server.id })
249266

250267
const client = await LSPClient.create({
251268
serverID: server.id,
252269
server: handle,
253270
root,
254271
}).catch(async (err) => {
255-
s.broken.add(key)
272+
if ((s.generation.get(key) ?? 0) === generation) s.broken.add(key)
256273
await Process.stop(handle.process)
257274
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
258275
return undefined
259276
})
260277

261278
if (!client) return undefined
262279

280+
if ((s.generation.get(key) ?? 0) !== generation) {
281+
await client.shutdown().catch(() => {})
282+
return undefined
283+
}
284+
263285
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
264286
if (existing) {
265-
await Process.stop(handle.process)
287+
await client.shutdown().catch(() => {})
266288
return existing
267289
}
268290

@@ -275,28 +297,29 @@ export namespace LSP {
275297

276298
const root = await server.root(file)
277299
if (!root) continue
278-
if (s.broken.has(root + server.id)) continue
300+
const key = keyFor(root, server.id)
301+
if (s.broken.has(key)) continue
279302

280303
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
281304
if (match) {
282305
result.push(match)
283306
continue
284307
}
285308

286-
const inflight = s.spawning.get(root + server.id)
309+
const inflight = s.spawning.get(key)
287310
if (inflight) {
288311
const client = await inflight
289312
if (!client) continue
290313
result.push(client)
291314
continue
292315
}
293316

294-
const task = schedule(server, root, root + server.id)
295-
s.spawning.set(root + server.id, task)
317+
const task = schedule(server, root, key)
318+
s.spawning.set(key, task)
296319

297320
task.finally(() => {
298-
if (s.spawning.get(root + server.id) === task) {
299-
s.spawning.delete(root + server.id)
321+
if (s.spawning.get(key) === task) {
322+
s.spawning.delete(key)
300323
}
301324
})
302325

@@ -339,6 +362,78 @@ export namespace LSP {
339362
return result
340363
})
341364

365+
const kill = Effect.fn("LSP.kill")(function* (name: string) {
366+
const s = yield* InstanceState.get(state)
367+
return yield* Effect.promise(async () => {
368+
const matches = s.clients.filter((client) => client.serverID === name)
369+
370+
if (matches.length > 0) {
371+
s.clients = s.clients.filter((client) => client.serverID !== name)
372+
}
373+
374+
for (const client of matches) {
375+
const key = keyFor(client.root, client.serverID)
376+
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
377+
}
378+
379+
await Promise.all(
380+
matches.map((client) =>
381+
client.shutdown().catch((error) => {
382+
log.error(`Failed to shutdown LSP client ${name}`, { error })
383+
}),
384+
),
385+
)
386+
387+
const spawning = [...s.spawning.keys()].filter((key) => keyMatchesServer(key, name))
388+
for (const key of spawning) {
389+
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
390+
s.spawning.delete(key)
391+
}
392+
393+
const broken = [...s.broken].filter((key) => keyMatchesServer(key, name))
394+
for (const key of broken) {
395+
s.broken.delete(key)
396+
}
397+
398+
const changed = matches.length > 0 || spawning.length > 0 || broken.length > 0
399+
if (!changed) return false
400+
await Bus.publish(Event.Updated, {})
401+
return true
402+
})
403+
})
404+
405+
const killAll = Effect.fn("LSP.killAll")(function* () {
406+
const s = yield* InstanceState.get(state)
407+
return yield* Effect.promise(async () => {
408+
const clients = [...s.clients]
409+
if (clients.length === 0 && s.spawning.size === 0 && s.broken.size === 0) return false
410+
411+
s.clients = []
412+
413+
for (const client of clients) {
414+
const key = keyFor(client.root, client.serverID)
415+
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
416+
}
417+
418+
await Promise.all(
419+
clients.map((client) =>
420+
client.shutdown().catch((error) => {
421+
log.error(`Failed to shutdown LSP client ${client.serverID}`, { error })
422+
}),
423+
),
424+
)
425+
426+
for (const key of s.spawning.keys()) {
427+
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
428+
}
429+
s.spawning.clear()
430+
s.broken.clear()
431+
432+
await Bus.publish(Event.Updated, {})
433+
return true
434+
})
435+
})
436+
342437
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
343438
const s = yield* InstanceState.get(state)
344439
return yield* Effect.promise(async () => {
@@ -347,7 +442,7 @@ export namespace LSP {
347442
if (server.extensions.length && !server.extensions.includes(extension)) continue
348443
const root = await server.root(file)
349444
if (!root) continue
350-
if (s.broken.has(root + server.id)) continue
445+
if (s.broken.has(keyFor(root, server.id))) continue
351446
return true
352447
}
353448
return false
@@ -490,6 +585,8 @@ export namespace LSP {
490585
return Service.of({
491586
init,
492587
status,
588+
kill,
589+
killAll,
493590
hasClients,
494591
touchFile,
495592
diagnostics,
@@ -514,6 +611,10 @@ export namespace LSP {
514611

515612
export const status = async () => runPromise((svc) => svc.status())
516613

614+
export const kill = async (name: string) => runPromise((svc) => svc.kill(name))
615+
616+
export const killAll = async () => runPromise((svc) => svc.killAll())
617+
517618
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
518619

519620
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>

0 commit comments

Comments
 (0)