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
117 changes: 116 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,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 { useTextareaKeybindings } from "../textarea-keybindings"
Expand Down Expand Up @@ -398,6 +399,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 @@ -537,6 +551,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 @@ -601,6 +703,19 @@ export function Prompt(props: PromptProps) {
exit()
return
}

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) {
promptModelWarning()
Expand All @@ -610,7 +725,7 @@ export function Prompt(props: PromptProps) {
let sessionID = props.sessionID
if (sessionID == null) {
const res = await sdk.client.session.create({
workspaceID: props.workspaceID,
workspace: props.workspaceID,
})

if (res.error) {
Expand Down
123 changes: 112 additions & 11 deletions packages/opencode/src/lsp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,29 @@ export namespace LSP {
}
}

const keyFor = (root: string, serverID: string) => `${root}\u0000${serverID}`

const keyMatchesServer = (key: string, serverID: string) => {
const split = key.lastIndexOf("\u0000")
if (split === -1) return false
return key.slice(split + 1) === serverID
}

type LocInput = { file: string; line: number; character: number }

interface State {
clients: LSPClient.Info[]
servers: Record<string, LSPServer.Info>
broken: Set<string>
spawning: Map<string, Promise<LSPClient.Info | undefined>>
generation: Map<string, number>
}

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, waitForDiagnostics?: boolean) => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
Expand Down Expand Up @@ -212,6 +223,7 @@ export namespace LSP {
servers,
broken: new Set(),
spawning: new Map(),
generation: new Map(),
}

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

async function schedule(server: LSPServer.Info, root: string, key: string) {
const generation = s.generation.get(key) ?? 0
const handle = await server
.spawn(root)
.then((value) => {
if (!value) s.broken.add(key)
if (!value && (s.generation.get(key) ?? 0) === generation) s.broken.add(key)
return value
})
.catch((err) => {
s.broken.add(key)
if ((s.generation.get(key) ?? 0) === generation) s.broken.add(key)
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
return undefined
})

if (!handle) return undefined
if ((s.generation.get(key) ?? 0) !== generation) {
handle.process.kill()
return undefined
}
log.info("spawned lsp server", { serverID: server.id })

const client = await LSPClient.create({
serverID: server.id,
server: handle,
root,
}).catch(async (err) => {
s.broken.add(key)
if ((s.generation.get(key) ?? 0) === generation) s.broken.add(key)
await Process.stop(handle.process)
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})

if (!client) return undefined

if ((s.generation.get(key) ?? 0) !== generation) {
await client.shutdown().catch(() => {})
return undefined
}

const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (existing) {
await Process.stop(handle.process)
await client.shutdown().catch(() => {})
return existing
}

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

const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
const key = keyFor(root, server.id)
if (s.broken.has(key)) continue

const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
if (match) {
result.push(match)
continue
}

const inflight = s.spawning.get(root + server.id)
const inflight = s.spawning.get(key)
if (inflight) {
const client = await inflight
if (!client) continue
result.push(client)
continue
}

const task = schedule(server, root, root + server.id)
s.spawning.set(root + server.id, task)
const task = schedule(server, root, key)
s.spawning.set(key, task)

task.finally(() => {
if (s.spawning.get(root + server.id) === task) {
s.spawning.delete(root + server.id)
if (s.spawning.get(key) === task) {
s.spawning.delete(key)
}
})

Expand Down Expand Up @@ -339,6 +362,78 @@ export namespace LSP {
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)
}

for (const client of matches) {
const key = keyFor(client.root, client.serverID)
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
}

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) => keyMatchesServer(key, name))
for (const key of spawning) {
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
s.spawning.delete(key)
}

const broken = [...s.broken].filter((key) => keyMatchesServer(key, 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 = []

for (const client of clients) {
const key = keyFor(client.root, client.serverID)
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
}

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

for (const key of s.spawning.keys()) {
s.generation.set(key, (s.generation.get(key) ?? 0) + 1)
}
s.spawning.clear()
s.broken.clear()

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

const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
const s = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
Expand All @@ -347,7 +442,7 @@ export namespace LSP {
if (server.extensions.length && !server.extensions.includes(extension)) continue
const root = await server.root(file)
if (!root) continue
if (s.broken.has(root + server.id)) continue
if (s.broken.has(keyFor(root, server.id))) continue
return true
}
return false
Expand Down Expand Up @@ -490,6 +585,8 @@ export namespace LSP {
return Service.of({
init,
status,
kill,
killAll,
hasClients,
touchFile,
diagnostics,
Expand All @@ -514,6 +611,10 @@ export namespace LSP {

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

export const kill = async (name: string) => runPromise((svc) => svc.kill(name))

export const killAll = async () => runPromise((svc) => svc.killAll())

export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))

export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
Expand Down
Loading
Loading