Skip to content

Commit 2fb94a4

Browse files
committed
fix(opencode): make /lsps actionable and add LSP kill endpoints
1 parent 9b6db08 commit 2fb94a4

8 files changed

Lines changed: 401 additions & 0 deletions

File tree

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts"
3434
import { useDialog } from "@tui/ui/dialog"
3535
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
3636
import { DialogAlert } from "../../ui/dialog-alert"
37+
import { DialogSelect } from "../../ui/dialog-select"
3738
import { useToast } from "../../ui/toast"
3839
import { useKV } from "../../context/kv"
3940
import { createFadeIn } from "../../util/signal"
@@ -411,6 +412,19 @@ export function Prompt(props: PromptProps) {
411412
))
412413
},
413414
},
415+
{
416+
title: "List LSP servers",
417+
value: "lsp.list",
418+
category: "System",
419+
description: "Use /lsps kill <name> to stop one",
420+
slash: {
421+
name: "lsps",
422+
},
423+
onSelect: async (dialog) => {
424+
dialog.clear()
425+
await runLsps("/lsps")
426+
},
427+
},
414428
]
415429
})
416430

@@ -571,6 +585,89 @@ export function Prompt(props: PromptProps) {
571585
)
572586
}
573587

588+
async function showLsps() {
589+
const result = await sdk.client.lsp.status().catch(() => undefined)
590+
if (!result || result.error) {
591+
toast.show({ message: "Failed to list LSP servers", variant: "error" })
592+
return
593+
}
594+
595+
const data = result.data ?? []
596+
if (data.length === 0) {
597+
toast.show({ message: "No LSP servers running", variant: "warning" })
598+
return
599+
}
600+
601+
dialog.replace(() => (
602+
<DialogSelect
603+
title="LSP Servers"
604+
options={data.map((item) => ({
605+
title: item.name,
606+
value: item.name,
607+
description: `/lsps kill ${item.name}`,
608+
footer: item.root || ".",
609+
}))}
610+
onSelect={async (option) => {
611+
await runLsps(`/lsps kill ${option.value}`)
612+
await showLsps()
613+
}}
614+
/>
615+
))
616+
}
617+
618+
async function runLsps(input: string) {
619+
const rest = input.replace(/^\/lsps/, "").trim()
620+
621+
if (!rest) {
622+
await showLsps()
623+
return true
624+
}
625+
626+
if (rest === "kill") {
627+
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
628+
return true
629+
}
630+
631+
if (rest === "killall") {
632+
const result = await sdk.client.lsp.killAll().catch(() => undefined)
633+
if (!result || result.error) {
634+
toast.show({ message: "Failed to kill LSP servers", variant: "error" })
635+
return true
636+
}
637+
if (!result.data) {
638+
toast.show({ message: "No LSP servers running", variant: "warning" })
639+
return true
640+
}
641+
toast.show({ message: "Killed all LSP servers", variant: "success" })
642+
return true
643+
}
644+
645+
if (!rest.startsWith("kill ")) {
646+
toast.show({ message: "Unknown /lsps command", variant: "warning" })
647+
return true
648+
}
649+
650+
const name = rest.slice("kill".length).trim()
651+
if (!name) {
652+
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
653+
return true
654+
}
655+
656+
const result = await sdk.client.lsp.kill({ name }).catch(() => undefined)
657+
if (!result || result.error) {
658+
toast.show({ message: `Failed to kill LSP server ${name}`, variant: "error" })
659+
return true
660+
}
661+
662+
if (!result.data) {
663+
toast.show({ message: `No running LSP server named ${name}`, variant: "warning" })
664+
return true
665+
}
666+
667+
toast.show({ message: `Killed LSP server ${name}`, variant: "success" })
668+
return true
669+
}
670+
574671
command.register(() => [
575672
{
576673
title: "Stash prompt",
@@ -644,6 +741,19 @@ export function Prompt(props: PromptProps) {
644741
void exit()
645742
return true
646743
}
744+
745+
if (store.mode === "normal" && trimmed.match(/^\/lsps(?:\s|$)/)) {
746+
await runLsps(trimmed)
747+
input.extmarks.clear()
748+
setStore("prompt", {
749+
input: "",
750+
parts: [],
751+
})
752+
setStore("extmarkToPartIndex", new Map())
753+
input.clear()
754+
return
755+
}
756+
647757
const selectedModel = local.model.current()
648758
if (!selectedModel) {
649759
void promptModelWarning()

packages/opencode/src/lsp/lsp.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ interface State {
135135
export interface Interface {
136136
readonly init: () => Effect.Effect<void>
137137
readonly status: () => Effect.Effect<Status[]>
138+
readonly kill: (name: string) => Effect.Effect<boolean>
139+
readonly killAll: () => Effect.Effect<boolean>
138140
readonly hasClients: (file: string) => Effect.Effect<boolean>
139141
readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>
140142
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
@@ -342,6 +344,64 @@ export const layer = Layer.effect(
342344
return result
343345
})
344346

347+
const kill = Effect.fn("LSP.kill")(function* (name: string) {
348+
const s = yield* InstanceState.get(state)
349+
return yield* Effect.promise(async () => {
350+
const matches = s.clients.filter((client) => client.serverID === name)
351+
352+
if (matches.length > 0) {
353+
s.clients = s.clients.filter((client) => client.serverID !== name)
354+
}
355+
356+
await Promise.all(
357+
matches.map((client) =>
358+
client.shutdown().catch((error) => {
359+
log.error(`Failed to shutdown LSP client ${name}`, { error })
360+
}),
361+
),
362+
)
363+
364+
const spawning = [...s.spawning.keys()].filter((key) => key.endsWith(name))
365+
for (const key of spawning) {
366+
s.spawning.delete(key)
367+
}
368+
369+
const broken = [...s.broken].filter((key) => key.endsWith(name))
370+
for (const key of broken) {
371+
s.broken.delete(key)
372+
}
373+
374+
const changed = matches.length > 0 || spawning.length > 0 || broken.length > 0
375+
if (!changed) return false
376+
await Bus.publish(Event.Updated, {})
377+
return true
378+
})
379+
})
380+
381+
const killAll = Effect.fn("LSP.killAll")(function* () {
382+
const s = yield* InstanceState.get(state)
383+
return yield* Effect.promise(async () => {
384+
const clients = [...s.clients]
385+
if (clients.length === 0 && s.spawning.size === 0 && s.broken.size === 0) return false
386+
387+
s.clients = []
388+
389+
await Promise.all(
390+
clients.map((client) =>
391+
client.shutdown().catch((error) => {
392+
log.error(`Failed to shutdown LSP client ${client.serverID}`, { error })
393+
}),
394+
),
395+
)
396+
397+
s.spawning.clear()
398+
s.broken.clear()
399+
400+
await Bus.publish(Event.Updated, {})
401+
return true
402+
})
403+
})
404+
345405
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
346406
const ctx = yield* InstanceState.context
347407
const s = yield* InstanceState.get(state)
@@ -499,6 +559,8 @@ export const layer = Layer.effect(
499559
return Service.of({
500560
init,
501561
status,
562+
kill,
563+
killAll,
502564
hasClients,
503565
touchFile,
504566
diagnostics,

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,58 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
272272
return yield* lsp.status()
273273
}),
274274
)
275+
.post(
276+
"/lsp/killall",
277+
describeRoute({
278+
summary: "Kill all LSP servers",
279+
description: "Kill all running LSP server instances.",
280+
operationId: "lsp.killAll",
281+
responses: {
282+
200: {
283+
description: "Whether any LSP server instances were killed",
284+
content: {
285+
"application/json": {
286+
schema: resolver(z.boolean()),
287+
},
288+
},
289+
},
290+
},
291+
}),
292+
async (c) =>
293+
jsonRequest("InstanceRoutes.lsp.killAll", c, function* () {
294+
const lsp = yield* LSP.Service
295+
return yield* lsp.killAll()
296+
}),
297+
)
298+
.post(
299+
"/lsp/:name/kill",
300+
describeRoute({
301+
summary: "Kill LSP server",
302+
description: "Kill all running instances of a specific LSP server.",
303+
operationId: "lsp.kill",
304+
responses: {
305+
200: {
306+
description: "Whether any LSP server instances were killed",
307+
content: {
308+
"application/json": {
309+
schema: resolver(z.boolean()),
310+
},
311+
},
312+
},
313+
},
314+
}),
315+
validator(
316+
"param",
317+
z.object({
318+
name: z.string().meta({ description: "LSP server name" }),
319+
}),
320+
),
321+
async (c) =>
322+
jsonRequest("InstanceRoutes.lsp.kill", c, function* () {
323+
const lsp = yield* LSP.Service
324+
return yield* lsp.kill(c.req.valid("param").name)
325+
}),
326+
)
275327
.get(
276328
"/formatter",
277329
describeRoute({

packages/opencode/test/fixture/fixture.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,19 @@ type TmpDirOptions<T> = {
4343
init?: (dir: string) => Promise<T>
4444
dispose?: (dir: string) => Promise<T>
4545
}
46+
47+
function gitIdentity() {
48+
process.env.GIT_AUTHOR_NAME ??= "opencode-test"
49+
process.env.GIT_AUTHOR_EMAIL ??= "opencode-test@localhost"
50+
process.env.GIT_COMMITTER_NAME ??= process.env.GIT_AUTHOR_NAME
51+
process.env.GIT_COMMITTER_EMAIL ??= process.env.GIT_AUTHOR_EMAIL
52+
}
53+
4654
export async function tmpdir<T>(options?: TmpDirOptions<T>) {
4755
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
4856
await fs.mkdir(dirpath, { recursive: true })
4957
if (options?.git) {
58+
gitIdentity()
5059
await $`git init`.cwd(dirpath).quiet()
5160
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
5261
await $`git config commit.gpgsign false`.cwd(dirpath).quiet()

packages/sdk/js/src/gen/sdk.gen.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ import type {
164164
McpConnectResponses,
165165
McpDisconnectData,
166166
McpDisconnectResponses,
167+
LspKillData,
168+
LspKillResponses,
169+
LspKillAllResponses,
167170
LspStatusData,
168171
LspStatusResponses,
169172
FormatterStatusData,
@@ -983,6 +986,26 @@ class Lsp extends _HeyApiClient {
983986
...options,
984987
})
985988
}
989+
990+
/**
991+
* Kill all LSP servers
992+
*/
993+
public killAll<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
994+
return (options?.client ?? this._client).post<LspKillAllResponses, unknown, ThrowOnError>({
995+
url: "/lsp/killall",
996+
...options,
997+
})
998+
}
999+
1000+
/**
1001+
* Kill LSP server
1002+
*/
1003+
public kill<ThrowOnError extends boolean = false>(options: Options<LspKillData, ThrowOnError>) {
1004+
return (options.client ?? this._client).post<LspKillResponses, unknown, ThrowOnError>({
1005+
url: "/lsp/{name}/kill",
1006+
...options,
1007+
})
1008+
}
9861009
}
9871010

9881011
class Formatter extends _HeyApiClient {

packages/sdk/js/src/gen/types.gen.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3576,6 +3576,44 @@ export type LspStatusResponses = {
35763576

35773577
export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]
35783578

3579+
export type LspKillData = {
3580+
body?: never
3581+
path: {
3582+
name: string
3583+
}
3584+
query?: {
3585+
directory?: string
3586+
}
3587+
url: "/lsp/{name}/kill"
3588+
}
3589+
3590+
export type LspKillResponses = {
3591+
/**
3592+
* Whether any LSP server instances were killed
3593+
*/
3594+
200: boolean
3595+
}
3596+
3597+
export type LspKillResponse = LspKillResponses[keyof LspKillResponses]
3598+
3599+
export type LspKillAllData = {
3600+
body?: never
3601+
path?: never
3602+
query?: {
3603+
directory?: string
3604+
}
3605+
url: "/lsp/killall"
3606+
}
3607+
3608+
export type LspKillAllResponses = {
3609+
/**
3610+
* Whether any LSP server instances were killed
3611+
*/
3612+
200: boolean
3613+
}
3614+
3615+
export type LspKillAllResponse = LspKillAllResponses[keyof LspKillAllResponses]
3616+
35793617
export type FormatterStatusData = {
35803618
body?: never
35813619
path?: never

0 commit comments

Comments
 (0)