diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md index 8ac7025f176b..02ab2d983994 100644 --- a/.opencode/agent/translator.md +++ b/.opencode/agent/translator.md @@ -422,6 +422,9 @@ opencode.server.close() /instance/dispose /log /lsp +/lsps +/lsps/killall +/lsps/kill/ /mcp /mnt/ /mnt/c/ diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 2e08e66a4a2d..370048a4d0b5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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" @@ -411,6 +412,19 @@ export function Prompt(props: PromptProps) { )) }, }, + { + title: "List LSP servers", + value: "lsp.list", + category: "System", + description: "Use /lsps kill to stop one", + slash: { + name: "lsps", + }, + onSelect: async (dialog) => { + dialog.clear() + await runLsps("/lsps") + }, + }, ] }) @@ -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(() => ( + { + 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 ", 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 ", 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", @@ -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() diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 4c46cd9aa776..fd89402bd5d1 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -135,6 +135,8 @@ interface State { export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect + readonly kill: (name: string) => Effect.Effect + readonly killAll: () => Effect.Effect readonly hasClients: (file: string) => Effect.Effect readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect readonly diagnostics: () => Effect.Effect> @@ -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) @@ -499,6 +559,8 @@ export const layer = Layer.effect( return Service.of({ init, status, + kill, + killAll, hasClients, touchFile, diagnostics, diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index e8a038fabc0a..6968780557b9 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -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({ diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index fd7f5e380876..08427c675332 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -43,10 +43,19 @@ type TmpDirOptions = { init?: (dir: string) => Promise dispose?: (dir: string) => Promise } + +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(options?: TmpDirOptions) { 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() diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 8ffb20f15419..1f16fd75245a 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -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({}), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 651754733909..bb02558638c8 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -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({}), diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts index 5e3e67e1c030..410862e65e0e 100644 --- a/packages/sdk/js/src/gen/sdk.gen.ts +++ b/packages/sdk/js/src/gen/sdk.gen.ts @@ -164,6 +164,9 @@ import type { McpConnectResponses, McpDisconnectData, McpDisconnectResponses, + LspKillData, + LspKillResponses, + LspKillAllResponses, LspStatusData, LspStatusResponses, FormatterStatusData, @@ -983,6 +986,26 @@ class Lsp extends _HeyApiClient { ...options, }) } + + /** + * Kill all LSP servers + */ + public killAll(options?: Options) { + return (options?.client ?? this._client).post({ + url: "/lsp/killall", + ...options, + }) + } + + /** + * Kill LSP server + */ + public kill(options: Options) { + return (options.client ?? this._client).post({ + url: "/lsp/{name}/kill", + ...options, + }) + } } class Formatter extends _HeyApiClient { diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe985..9c7aa3695107 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -3576,6 +3576,44 @@ export type LspStatusResponses = { export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] +export type LspKillData = { + body?: never + path: { + name: string + } + query?: { + directory?: string + } + url: "/lsp/{name}/kill" +} + +export type LspKillResponses = { + /** + * Whether any LSP server instances were killed + */ + 200: boolean +} + +export type LspKillResponse = LspKillResponses[keyof LspKillResponses] + +export type LspKillAllData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/lsp/killall" +} + +export type LspKillAllResponses = { + /** + * Whether any LSP server instances were killed + */ + 200: boolean +} + +export type LspKillAllResponse = LspKillAllResponses[keyof LspKillAllResponses] + export type FormatterStatusData = { body?: never path?: never diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 6248eb8e4d64..7cc6d5dc9b4d 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -56,6 +56,8 @@ import type { GlobalUpgradeErrors, GlobalUpgradeResponses, InstanceDisposeResponses, + LspKillAllResponses, + LspKillResponses, LspStatusResponses, McpAddErrors, McpAddResponses, @@ -4288,6 +4290,68 @@ export class Lsp extends HeyApiClient { ...params, }) } + + /** + * Kill all LSP servers + * + * Kill all running LSP server instances. + */ + public killAll( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/killall", + ...options, + ...params, + }) + } + + /** + * Kill LSP server + * + * Kill all running instances of a specific LSP server. + */ + public kill( + parameters: { + name: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "name" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/lsp/{name}/kill", + ...options, + ...params, + }) + } } 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 1fcab2eda6d6..9a553a20cd2f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -5472,6 +5472,49 @@ export type LspStatusResponses = { export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses] +export type LspKillAllData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/lsp/killall" +} + +export type LspKillAllResponses = { + /** + * Whether any LSP server instances were killed + */ + 200: boolean +} + +export type LspKillAllResponse = LspKillAllResponses[keyof LspKillAllResponses] + +export type LspKillData = { + body?: never + path: { + /** + * LSP server name + */ + name: string + } + query?: { + directory?: string + workspace?: string + } + url: "/lsp/{name}/kill" +} + +export type LspKillResponses = { + /** + * Whether any LSP server instances were killed + */ + 200: boolean +} + +export type LspKillResponse = LspKillResponses[keyof LspKillResponses] + export type FormatterStatusData = { body?: never path?: never