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
3 changes: 3 additions & 0 deletions .opencode/agent/translator.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ opencode.server.close()
/instance/dispose
/log
/lsp
/lsps
/lsps/killall
/lsps/kill/<name>
/mcp
/mnt/
/mnt/c/
Expand Down
115 changes: 115 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { createColors, createFrames } from "../../ui/spinner.ts"
import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { DialogSelect } from "../../ui/dialog-select"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
Expand Down Expand Up @@ -411,6 +412,19 @@ export function Prompt(props: PromptProps) {
))
},
},
{
title: "List LSP servers",
value: "lsp.list",
category: "System",
description: "Use /lsps kill <name> to stop one",
slash: {
name: "lsps",
},
onSelect: async (dialog) => {
dialog.clear()
await runLsps("/lsps")
},
},
]
})

Expand Down Expand Up @@ -571,6 +585,94 @@ export function Prompt(props: PromptProps) {
)
}

async function showLsps() {
const result = await sdk.client.lsp.status().catch(() => undefined)
if (!result || result.error) {
toast.show({ message: "Failed to list LSP servers", variant: "error" })
return
}

const data = result.data ?? []
const opts = [
{
title: "Kill all LSP servers",
value: "/lsps killall",
description: "Stop every running LSP",
footer: `${data.length} running`,
},
...data.map((item) => ({
title: item.name,
value: `/lsps kill ${item.name}`,
description: item.root || ".",
footer: "kill",
})),
]

dialog.replace(() => (
<DialogSelect
title="LSP Servers"
options={opts}
onSelect={async (option) => {
await runLsps(option.value)
await showLsps()
}}
/>
))
}

async function runLsps(input: string) {
const rest = input.replace(/^\/lsps/, "").trim()

if (!rest) {
await showLsps()
return true
}

if (rest === "kill") {
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
return true
}

if (rest === "killall") {
const result = await sdk.client.lsp.killAll().catch(() => undefined)
if (!result || result.error) {
toast.show({ message: "Failed to kill LSP servers", variant: "error" })
return true
}
if (!result.data) {
toast.show({ message: "No LSP servers running", variant: "warning" })
return true
}
toast.show({ message: "Killed all LSP servers", variant: "success" })
return true
}

if (!rest.startsWith("kill ")) {
toast.show({ message: "Unknown /lsps command", variant: "warning" })
return true
}

const name = rest.slice("kill".length).trim()
if (!name) {
toast.show({ message: "Usage: /lsps kill <name>", variant: "warning" })
return true
}

const result = await sdk.client.lsp.kill({ name }).catch(() => undefined)
if (!result || result.error) {
toast.show({ message: `Failed to kill LSP server ${name}`, variant: "error" })
return true
}

if (!result.data) {
toast.show({ message: `No running LSP server named ${name}`, variant: "warning" })
return true
}

toast.show({ message: `Killed LSP server ${name}`, variant: "success" })
return true
}

command.register(() => [
{
title: "Stash prompt",
Expand Down Expand Up @@ -644,6 +746,19 @@ export function Prompt(props: PromptProps) {
void exit()
return true
}

if (store.mode === "normal" && trimmed.match(/^\/lsps(?:\s|$)/)) {
await runLsps(trimmed)
input.extmarks.clear()
setStore("prompt", {
input: "",
parts: [],
})
setStore("extmarkToPartIndex", new Map())
input.clear()
return
}

const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
Expand Down
62 changes: 62 additions & 0 deletions packages/opencode/src/lsp/lsp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ interface State {
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly kill: (name: string) => Effect.Effect<boolean>
readonly killAll: () => Effect.Effect<boolean>
readonly hasClients: (file: string) => Effect.Effect<boolean>
readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
Expand Down Expand Up @@ -342,6 +344,64 @@ export const layer = Layer.effect(
return result
})

const kill = Effect.fn("LSP.kill")(function* (name: string) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const matches = s.clients.filter((client) => client.serverID === name)

if (matches.length > 0) {
s.clients = s.clients.filter((client) => client.serverID !== name)
}

await Promise.all(
matches.map((client) =>
client.shutdown().catch((error) => {
log.error(`Failed to shutdown LSP client ${name}`, { error })
}),
),
)

const spawning = [...s.spawning.keys()].filter((key) => key.endsWith(name))
for (const key of spawning) {
s.spawning.delete(key)
}

const broken = [...s.broken].filter((key) => key.endsWith(name))
for (const key of broken) {
s.broken.delete(key)
}

const changed = matches.length > 0 || spawning.length > 0 || broken.length > 0
if (!changed) return false
await Bus.publish(Event.Updated, {})
return true
})
})

const killAll = Effect.fn("LSP.killAll")(function* () {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const clients = [...s.clients]
if (clients.length === 0 && s.spawning.size === 0 && s.broken.size === 0) return false

s.clients = []

await Promise.all(
clients.map((client) =>
client.shutdown().catch((error) => {
log.error(`Failed to shutdown LSP client ${client.serverID}`, { error })
}),
),
)

s.spawning.clear()
s.broken.clear()

await Bus.publish(Event.Updated, {})
return true
})
})

const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const ctx = yield* InstanceState.context
const s = yield* InstanceState.get(state)
Expand Down Expand Up @@ -499,6 +559,8 @@ export const layer = Layer.effect(
return Service.of({
init,
status,
kill,
killAll,
hasClients,
touchFile,
diagnostics,
Expand Down
52 changes: 52 additions & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,58 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
return yield* lsp.status()
}),
)
.post(
"/lsp/killall",
describeRoute({
summary: "Kill all LSP servers",
description: "Kill all running LSP server instances.",
operationId: "lsp.killAll",
responses: {
200: {
description: "Whether any LSP server instances were killed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
async (c) =>
jsonRequest("InstanceRoutes.lsp.killAll", c, function* () {
const lsp = yield* LSP.Service
return yield* lsp.killAll()
}),
)
.post(
"/lsp/:name/kill",
describeRoute({
summary: "Kill LSP server",
description: "Kill all running instances of a specific LSP server.",
operationId: "lsp.kill",
responses: {
200: {
description: "Whether any LSP server instances were killed",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
validator(
"param",
z.object({
name: z.string().meta({ description: "LSP server name" }),
}),
),
async (c) =>
jsonRequest("InstanceRoutes.lsp.kill", c, function* () {
const lsp = yield* LSP.Service
return yield* lsp.kill(c.req.valid("param").name)
}),
)
.get(
"/formatter",
describeRoute({
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/test/fixture/fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,19 @@ type TmpDirOptions<T> = {
init?: (dir: string) => Promise<T>
dispose?: (dir: string) => Promise<T>
}

function gitIdentity() {
process.env.GIT_AUTHOR_NAME ??= "opencode-test"
process.env.GIT_AUTHOR_EMAIL ??= "opencode-test@localhost"
process.env.GIT_COMMITTER_NAME ??= process.env.GIT_AUTHOR_NAME
process.env.GIT_COMMITTER_EMAIL ??= process.env.GIT_AUTHOR_EMAIL
}

export async function tmpdir<T>(options?: TmpDirOptions<T>) {
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
await fs.mkdir(dirpath, { recursive: true })
if (options?.git) {
gitIdentity()
await $`git init`.cwd(dirpath).quiet()
await $`git config core.fsmonitor false`.cwd(dirpath).quiet()
await $`git config commit.gpgsign false`.cwd(dirpath).quiet()
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ const lsp = Layer.succeed(
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
kill: () => Effect.succeed(false),
killAll: () => Effect.succeed(false),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/session/snapshot-tool-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ const lsp = Layer.succeed(
LSP.Service.of({
init: () => Effect.void,
status: () => Effect.succeed([]),
kill: () => Effect.succeed(false),
killAll: () => Effect.succeed(false),
hasClients: () => Effect.succeed(false),
touchFile: () => Effect.void,
diagnostics: () => Effect.succeed({}),
Expand Down
23 changes: 23 additions & 0 deletions packages/sdk/js/src/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ import type {
McpConnectResponses,
McpDisconnectData,
McpDisconnectResponses,
LspKillData,
LspKillResponses,
LspKillAllResponses,
LspStatusData,
LspStatusResponses,
FormatterStatusData,
Expand Down Expand Up @@ -983,6 +986,26 @@ class Lsp extends _HeyApiClient {
...options,
})
}

/**
* Kill all LSP servers
*/
public killAll<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
return (options?.client ?? this._client).post<LspKillAllResponses, unknown, ThrowOnError>({
url: "/lsp/killall",
...options,
})
}

/**
* Kill LSP server
*/
public kill<ThrowOnError extends boolean = false>(options: Options<LspKillData, ThrowOnError>) {
return (options.client ?? this._client).post<LspKillResponses, unknown, ThrowOnError>({
url: "/lsp/{name}/kill",
...options,
})
}
}

class Formatter extends _HeyApiClient {
Expand Down
Loading
Loading