The working checklist for building LSP into the editor. The argument — why a
terminal should do this at all, what it costs, and what will go wrong — lives in
LSP.md and is not repeated here. This document is what gets built,
in what order, and how far along it is.
Status: [x] done · [~] done but unproven on a real server · [ ] todo
Pairs with LSP.md the way WINDOWS-TASKS.md
pairs with WINDOWS-SUPPORT.md: rationale there,
checkboxes here.
┌──────────────────────────── webview ─────────────────────────────┐
│ EditorSurface │
│ └─ lsp/ CodeMirror ⇄ LSP: sync, diagnostics, │
│ │ hover, completion, signature, actions │
│ services/lsp/ │
│ client.ts one server: handshake, sync, requests │
│ manager.ts many servers: keyed, lazy, idle-stopped │
│ position.ts offset ⇄ {line, character}, both │
│ encodings │
│ servers.ts the table: language → program │
│ protocol.ts the slice of LSP types we use │
└──────────────────────────────┬────────────▲──────────────────────┘
invoke │ │ event
lsp_send() │ │ "lsp://message"
┌──────────────────────────────▼────────────┴──────────────────────┐
│ commands/lsp.rs ──► lsp/{server,registry,framing}.rs │
│ spawn · PATH resolution · Content-Length framing · reader │
│ thread · stderr capture · kill · reap on quit │
└──────────────────────────────────────────────────────────────────┘
Rust never parses a request. It reads a Content-Length header and emits the
body as an opaque JSON value. Adding textDocument/foo is then a TypeScript
change and the Rust side keeps one job it can be tested on.
Carried from LSP.md, plus the three this document settles.
| Question | Decision |
|---|---|
| Bundle or download servers? | Neither. Use what is on PATH, like git. |
| Where does the client live? | Split. Rust owns process and framing; TypeScript owns protocol. |
| Start automatically? | No. Off by default; on per language once the master switch is on. |
| One server per what? | Per (workspace root, server id). Lazy start, idle stop. |
| Position encoding | Ask for UTF-16, honour what comes back. |
| Document sync | Incremental, batched per animation frame. |
| Semantic tokens | Out — they would fight the Lezer highlighting. |
| A protocol library? | No. protocol.ts declares the ~200 lines of types we use. A dependency for types we delete 95% of is not worth the install. |
| Which servers? | Official first-party ones only — the list below. No community forks, no "best of" opinions to maintain. |
| Where is the switch? | Editor Settings → Language Server. Master toggle, then one per language, then what was detected on PATH. |
The bar is: the implementation its own ecosystem ships or has settled on, it
speaks stdio, and it needs no bespoke bootstrapping. That last clause is
what keeps jdtls out (a per-workspace data directory), Roslyn's
Microsoft.CodeAnalysis.LanguageServer (a non-standard solution/open
notification before it will analyse anything), and Vue (a TypeScript plugin to
wire in) — they need handling, not a table row.
PHP and C# are the two places the "first-party" rule is relaxed. Neither has a
first-party server that a generic client can drive: intelephense is what the
PHP ecosystem uses, and csharp-ls is a plain stdio server that finds the
.sln or .csproj itself — where the official Roslyn server is a NuGet payload
that has to be told about the solution out-of-band.
| Language | Program | Install |
|---|---|---|
| TypeScript / JavaScript | typescript-language-server --stdio |
npm i -g typescript-language-server typescript |
| Rust | rust-analyzer |
rustup component add rust-analyzer |
| Python | pyright-langserver --stdio |
npm i -g pyright |
| Go | gopls |
go install golang.org/x/tools/gopls@latest |
| C / C++ / Objective-C | clangd |
Ships with LLVM |
| PHP | intelephense --stdio |
npm i -g intelephense |
| Ruby | ruby-lsp |
gem install ruby-lsp |
| Swift | sourcekit-lsp |
Included with a swift.org toolchain |
| Zig | zls |
Download from github.com/zigtools/zls/releases |
| Dart | dart language-server |
Included with the Dart and Flutter SDKs |
| Kotlin | kotlin-language-server |
Download from github.com/fwcd/kotlin-language-server/releases |
| C# | csharp-ls |
dotnet tool install --global csharp-ls |
| Svelte | svelteserver --stdio |
npm i -g svelte-language-server |
| Terraform | terraform-ls serve |
Download from releases.hashicorp.com/terraform-ls |
| TOML | taplo lsp stdio |
cargo install taplo-cli --features lsp |
| Markdown | marksman server |
Download from github.com/artempyanykh/marksman/releases |
| Dockerfile | docker-langserver --stdio |
npm i -g dockerfile-language-server-nodejs |
| JSON | vscode-json-language-server --stdio |
npm i -g vscode-langservers-extracted |
| CSS / SCSS / Less | vscode-css-language-server --stdio |
as above |
| HTML | vscode-html-language-server --stdio |
as above |
| YAML | yaml-language-server --stdio |
npm i -g yaml-language-server |
| Shell | bash-language-server start |
npm i -g bash-language-server |
| Lua | lua-language-server |
Download from github.com/LuaLS/lua-language-server/releases |
| Tailwind CSS (companion) | tailwindcss-language-server --stdio |
npm i -g @tailwindcss/language-server |
The install column is the platform-neutral instruction. Where a package
manager is quicker — brew, apt, winget — the settings panel shows that one
instead; see installOn in servers.ts.
A missing server is a first-class state, not a silent no-op: the settings panel says "not on PATH" against it and shows the install line, and the status bar says so when a file of that language is open.
-
lsp/framing.rs—Content-Lengthread and write. Counts bytes, not characters;\r\nexactly; handles a message split across reads and two messages in one read. -
lsp/server.rs— spawn one child, reader thread per server emitting one event per message, stderr on its own thread, write to stdin, politeshutdown/exitthen kill on a deadline. -
lsp/registry.rs—HashMap<id, Server>behind aMutex, a cap on how many run at once,shutdown_all. -
commands/lsp.rs—lsp_start,lsp_send,lsp_stop,lsp_status,lsp_detect. - Reuse
resolve_programfromshell_exec.rsfor WindowsPATHEXT— every Node-based server in the table above is a.cmdshim there. - Repair a truncated
PATHthe wayexecute_shell_commanddoes, or a bundled app finds none of these. - Reap on quit — join
shutdown_terminalsonRunEvent::Exit, or arust-analyzeroutlives the app. -
cargo testfor framing: multi-byte body, split read, coalesced read, oversized header refused.
-
protocol.ts— the slice of LSP we use, hand-declared. -
position.ts— offset ⇄{line, character}under both UTF-16 and UTF-8, andRange⇄ CodeMirror offsets. -
servers.ts— the table above, plus user overrides from settings. -
client.ts—initializehandshake and capability record, request correlation with timeouts and$/cancelRequest, incremental document sync with monotonic versions,didOpen/didChange/didClose. -
manager.ts— one client per(root, server), lazy start, idle stop, restart with backoff and a ceiling, a cap, and a status snapshot for the UI. - Batch
didChangeper animation frame — every keystroke reaches the server, but three in one frame are one message. - Skip large and read-only buffers — over
LARGE_FILE_BYTESthe editor is read-only with highlighting off, and a server has nothing to add. - The watcher's reload path sends
didChange— a file changed on disk under a clean buffer, or the server answers about the old text forever.
-
EditorSettings.lsp— master toggle, off by default. -
EditorSettings.lspServers— per-server overrides, keyed by the ids inservers.ts: on/off, and the program and arguments. One field rather than two, because "turn Rust off" and "point Rust at my own build" are the same kind of change to the same row. Absent means on, with the defaults. Checked on the way out oflocalStoragebysanitizeLspServers, since these become a process to spawn. - Editor Settings panel section — master toggle, per-language rows each showing detected / not on PATH with its install line, and a Restart button.
- Status bar item — which server is attached, what it is doing (starting, indexing, ready, failed), click to restart or stop.
The push direction: no correlation, no cancellation. Highest value, lowest risk, and it proves the transport end to end.
-
textDocument/publishDiagnosticsinto@codemirror/lint. - Severity, source and code rendered in the tooltip.
- Cleared on
didClose, on the server dying, and on the toggle going off. -
@codemirror/lintpromoted to a direct dependency (it is currently transitive vialang-javascript).
The first request/response, so correlation, timeouts and cancellation arrive here.
- Hover —
textDocument/hover, markdown or plaintext, rendered as a CodeMirror tooltip. Untrusted input: another program's output going into a tooltip, so no raw HTML. - Completion —
textDocument/completion, replacing the word-completion source rather than sitting beside it. Trigger characters,isIncompletere-query,completionItem/resolvefor documentation,sortText/filterTextoverriding CodeMirror's own ranking,textEditapplied as the server wrote it, snippets. - Signature help —
textDocument/signatureHelpon(and,.
- Go to definition —
textDocument/definition, through the existingopenPath(path, line, column)and the go-to queue. - Find references —
textDocument/references, into the same keyboard picker as go-to-definition (paths shown relative to the workspace root), not the project-search panel.GlobalSearchdrives its ownfs_searchand owns its results; feeding it a list from elsewhere would have meant taking that apart for a second caller. - Document symbols —
textDocument/documentSymbol, as a go-to-symbol picker (⌘⇧O) rather than an outline panel. The question people actually ask is "where isparseHunkin this file", and a picker answers it from the keyboard without taking a fourth slot on the side of the modal that already holds the explorer, search and git. - ⌘-click and a context-menu item for go-to-definition, plus F12, ⇧F12, F2, ⌘. and ⌘⇧O — all only offered when a server is attached, since a menu item that silently does nothing is worse than its absence.
These write files, including files that are not open. Different risk class: a
WorkspaceEdit must go through the same atomic write, mtime conflict check and
line-ending preservation as a save.
- Format —
textDocument/formattingandrangeFormatting, applied as one undoable edit. - Rename —
textDocument/rename, previewed as a file/count summary before it is applied. - Code actions —
textDocument/codeActionon the diagnostic under the cursor, offered from the lint tooltip. -
WorkspaceEditapplier — open buffers through CodeMirror (one undo step), closed files throughfs_write_textwith the mtime check. Not transactional, and does not claim to be: every file is read and every range resolved before anything is written, so the failures that can be caught are caught while nothing has changed — but a write that fails halfway reports which files changed rather than pretending it rolled back. A cross-file journal would be a larger project than the feature.
Automated, and what they found:
-
cargo testcovers the framing against the three things that go wrong — a multi-byte body whereContent-Lengthand character count differ, a message split at every byte offset, and two messages in one read — plus the refusals (endless header, unparseable length, implausible length). -
npm testcovers the encoding trap:é,👋and a tab, round-tripped at every code-unit boundary under all three encodings, including the case where a UTF-8 byte offset lands inside a surrogate pair. -
cargo test --test lsp_real_serverspawns whatever servers are installed and completes a real handshake with each. It distinguishes three outcomes, and the distinction is the whole point: a reply that arrives at all — framed, parseable, carrying our id — has proven the spawn, the pipes, the framing, the reader thread and the correlation. Only-32700,-32600and-32602mean we sent something malformed, and only those fail the test. A server that declines the workspace is counted and reported, not failed.On the development machine:
clangdcompletes and negotiates UTF-16;typescript-language-serverreplies and declines a bare temp directory, which is correct of it. Both were found through the repairedPATHrather than the process's own, which is the mechanism a bundled app depends on entirely.It also found something worth writing down:
rust-analyzeronPATHis often rustup's shim rather than the program. With the component not installed the shim exits immediately with "Unknown binary 'rust-analyzer' in official toolchain". Detection cannot see this — the file is there and it is executable — so it is exactly the case the stderr capture inlsp/server.rsexists for, and the user sees those words in the settings panel rather than a server that silently never starts. -
cargo test— framing round-trips, split and coalesced reads, multi-byte bodies,resolve_program. -
cargo test— position conversion under both encodings against a line containingé,👋and a tab (mirrored in the TS unit underposition.ts). -
tscclean,cargo checkclean. -
Manual, macOS — opening a
.ts/.tsxfile with a server attached. This failed the first time and is worth recording. The buffer opened blank, with onlyConfig merge conflict for field overridein the console. Twoautocompletion()extensions — the word list and the server's — were both in the state, and CodeMirror refuses to merge twooverrideconfigs. The word list was being cleared when a server attached, but by areconfigureapplied afterEditorState.create, which never ran because the create itself threw. Fixed by choosing the one source before the state is built, pinned bycompletion-exclusive.test.ts, andactivate()now falls back to a read-only view instead of leaving an empty pane if anything else ever throws there. -
Manual, macOS — TypeScript file with an error: squiggle appears and clears without stuttering while typing.
-
Manual — a line with
éand👋: hover and diagnostics land on the right characters under both negotiated encodings (rust-analyzeris the UTF-8 case). -
Manual — kill the server from the terminal behind: the editor says so, does not hang, and restarts on the next edit.
-
Manual — open a large Rust repository with the toggle off: nothing starts, nothing slows down.
-
Manual — quit with a server running: no orphaned process.
-
Manual, Windows — a Node-based server resolves through
PATHEXT.
The features worked; they did not yet look like the rest of the app. This section is the pass that closes that gap.
On the positioning library. Popper.js was suggested and is the wrong thing
to add: @floating-ui/dom is already a dependency, already positions the
terminal's suggestion popup, and is Popper's successor — same author, Popper
v3 renamed. A second positioning library would be two answers to one question.
-
@floating-ui/domunder every LSP overlay —computePositionwithoffset/flip/shift/size, matchingSuggestionPopup.tsx. The hand-rolledanchor()inpopup.tsflips only vertically, cannot shift horizontally off a narrow modal, and does not follow the text when the editor scrolls under it. -
autoUpdateso an overlay tracks its character position through scrolling, resizing and the modal being dragged, rather than being placed once and left behind. - ⌘-hover definition underline — the VS Code affordance: hold ⌘ (Ctrl elsewhere) and the symbol under the pointer underlines and takes a pointer cursor only when the server actually has a definition for it, so the underline is a promise rather than a guess. Clicking follows it. Debounced, cancelled on move, cleared on keyup, blur and scroll.
- Completion list — a kind glyph per item the way every editor has one,
the server's
detailright-aligned and dimmed, deprecated items struck through, and a documentation panel that doesn't jump as items are arrowed through. - Hover — the signature rendered as code in a header, ruled off from the prose below it, instead of one undifferentiated block of text.
- Diagnostics — severity-coloured tooltip and gutter that match the
editor's own palette rather than
@codemirror/lint's defaults. - Status bar and settings — quiet when healthy, loud when not: a ready server shows a dot and no text, because the language is already named by the picker beside it, while indexing, starting, missing and failed all say so in words. Servers that aren't installed stay listed but dimmed, and Restart is only offered where a process exists to restart.
- Both themes — every new rule has a
.lightcounterpart. - The picker closes three ways — Escape, a close button, and a click
outside it. The outside click was missing and is the one most people reach
for first; it listens on
mousedownso the panel is gone before the press reaches the editor behind, and it is registered a tick late so the gesture that opened the panel cannot immediately close it. - References read as results, not line numbers — file name, directory,
line badge, and the source line itself under each row. Open buffers
answer from memory; closed files are read in parallel, capped at 40.
axios-server.ts:13does not tell you which of twelve results you want;const authHeaders = getAuthenticatedHeaders(session)does. - Opening a result lands on the line — it often didn't.
activate()restored the buffer's saved scroll offset in arequestAnimationFramewhile the modal applied the queuedgoToin one of its own; whichever ran second won, and when it was the restore you got the right file scrolled to the top. The restore is now cancellable and an explicitgoTocancels it. - One owner per surface —
editorTheme.tsowns the tooltip box, which it can colour from the palette;styles.cssowns the content and the row layout. Both were setting backgrounds with different hardcoded greys, which is why the hover tooltip indocs/issuesdid not match the completion list beside it. - Manual — ⌘-hover an import, a local variable and a keyword: only the first two underline, and only where the server resolves them.
- Manual — a completion list at the bottom edge of a short modal, and a code-action menu at its right edge: both stay inside the window.
Pyright is only as useful as the environment it resolves imports against. Point
it at the system Python and every third-party import in a project with a .venv
is reported as missing — which looks exactly like a broken language server.
Why it did not work before: the client answered every
workspace/configuration request with an empty object. That is a legal answer
and a useless one — it is the request pyright uses to ask "which interpreter?",
and the empty answer means "the default".
-
Detect interpreters in the workspace —
.venv,venv,env,.virtualenv, and theScriptsrather thanbinlayout on Windows.uv,python -m venv,virtualenvandpoetry --in-projectall produce one of these, which is why the list is short.**This did not work on the first attempt and the reason is worth keeping.** Detection went through `fs_stat`, which canonicalises a path and checks it against the workspace roots — and `.venv/bin/python` is a *symlink to the base interpreter*, which uv points at `/opt/homebrew/opt/python@3.13/bin/python3.13`. It resolved out of the project, the confinement check rejected it, and the only interpreter the user actually wanted was the only one that could never be listed. It goes through `lsp_detect` now — the same executable lookup the servers use, which has no notion of roots. -
Show the version, and mark the recommended one —
Python 3.13.3 ('.venv')with a Recommended badge, asked of the binary rather than inferred from its path. With three Pythons listed and no guidance, the one that resolves the project's imports is indistinguishable from two that don't. -
Detect once, not twice — the modal owns the list and the picker reads it. Both were detecting independently, which with version probes meant two rounds of subprocesses for one answer.
-
Detect interpreters on
PATH—python3andpython, through the existinglsp_detect, as the fallback when a project has no environment. -
Auto-select the in-project one when there is exactly one, because that is the answer in the overwhelming majority of cases and asking would be asking a question with one possible answer.
-
Let the user pick — a status-bar item beside the language, shown only for Python files, listing what was found. VS Code's placement, because it is where people already look for it.
-
Serve it to the server — real answers to
workspace/configurationforpythonandpython.analysis, not{}. -
Remember it per workspace — an interpreter is a property of a project, not of the editor.
-
Restart pyright when it changes — it resolves the environment once, at startup, and will not notice a new one otherwise.
-
git ls-files --others --ignored --exclude-standard --directory— one call per workspace, giving ignored paths with directories collapsed, sonode_modulesis one entry rather than forty thousand. - Dim them in the explorer, the way every editor does, so the tree says what is and is not part of the project without being read.
- No change marks when one is open. Git reports an untracked file as
wholly new, so the gutter paints every line as added — on a build artefact
or a
.envthat is forty thousand green bars describing nothing. An ignored file is not "new work"; it is not work at all.
- No
uv/poetry/pipenvintegration. All four produce a directory in the project withbin/pythonin it, so detecting the directory covers every one of them and stays correct when the next tool arrives. Shelling out to each to ask where its environment lives would be four subprocesses, four output formats, and a list that goes stale. - No conda or pyenv enumeration. Both live outside the project and neither has a stable place to look. They are reachable by picking the interpreter, and guessing wrong is worse than not guessing.
- Ignored state is refreshed on a slow timer (10s) rather than on every
watcher burst. It only changes when someone edits a
.gitignore, whilegit statuschanges on every save — one process per keystroke pause for a fact that moves once a month is not a trade worth making.
Three separate leaks, one symptom: something floating over the code that the
user could not dismiss. Two are visible in docs/issues.
- Hover tooltips survived edits and cursor moves.
hoverTooltip'shideOnChangedefaults to false — CodeMirror deliberately keeps one open across changes unless told otherwise. A hover describes what is under the pointer at a moment, and the moment ends when the document or the selection changes. - Signature tooltips survived the call they described. The field cleared
only on
docChanged, on the theory that the plugin would replace it when the cursor moved. It does — when the request succeeds. Cancelled, timed out, or moved somewhere that is not a call, nothing replaced it. It now clears on any document or selection change and lets the answer re-show; a late answer for a position the cursor has left is dropped. Escape dismisses it, and falls through when there is nothing to dismiss. - The pickers survived a tab switch — and this is the "I opened
b.pyand it is showinga.py" report. The references list, the code-action menu and the rename box are plain DOM appended to the editor, because they anchor to a character position; swapping the view's state does not remove them. Closed now on every buffer switch and on teardown. - Diagnostics could paint one file onto another. Subtler and worse than
the rest: the surface swaps states in a single shared
EditorView, so a dispatch arriving after the swap lands on whichever file is in front — squiggles at offsets resolved against the wrong text. The plugin now marks itself gone on destroy and drops anything that arrives after. -
signature.test.tspins the clearing rule, which is one line and exactly the kind of thing that gets quietly reverted.
The Language Server section had outgrown the panel it was in: a master toggle, a format-on-save toggle and a table of eleven servers with their state and install lines, wedged into a single scrolling column between "word wrap" and "show hidden files". That is not where a feature with a table in it belongs.
- Five tabs — Appearance, Editing, Language Server, Files, Diff — with a left rail rather than a strip along the top: five labels do not fit across a modal this narrow without truncating to initials, and a rail is what the app's own Settings dialog already uses.
- The modal grew from 420px wide and free-height to 620×440, so the server table has room and switching tabs does not resize the window.
- A real
tablist—role,aria-selected,aria-controls, arrow-key navigation with wrapping, Home/End, and rovingtabIndexso Tab moves past the rail into the controls instead of through five buttons first. - The server table got a header — a count answering the question people open the tab with ("is anything going to happen?") and the re-check button beside it rather than stranded under the last row.
- And a search box, once the table reached twenty-four rows — past the
point where scanning beats searching. It matches the label, the program,
the id and the files each server claims, because the thing people
arrive knowing is usually the file: "what handles
.tsx" is a far more common question than "istypescript-language-serverinstalled", and searching by name requires already knowing the answer. A leading dot is ignored, several terms narrow rather than widen, and Escape clears the box only while there is something in it — otherwise it falls through and closes the dialog, which is what Escape does everywhere else. -
search.test.ts— eleven cases, including two that hold the table to account rather than the matcher: every server must be reachable by its own label, and by one of its extensions. An entry that can be seen but never found is a bug the matcher alone would never reveal.
Twelve more servers went in, and two structural problems had to be fixed first — both of which the eleven-entry table had been hiding.
- The table keys on file extensions, not the editor's language ids.
Those ids pick a highlighting grammar and are approximate:
.ktis highlighted ascpp,.svelteashtml,.scalaascpp. A Kotlin server keyed oncppwould have started on C++ files. Nothing caught it because until now every server's language happened to have its own grammar. - Each extension carries the language id to send.
didOpenspeaks the protocol's vocabulary, not ours —typescript-language-serverwantstypescriptreactfor a.tsxfile and does the wrong thing withtsx. That was already wrong for every.tsxfile before these servers arrived. - Companion servers, so Tailwind can work at all. It completes class names inside HTML, CSS and JSX, all of which already have a server; the one-server-per-file model would have forced it to replace one of them. Now a file can have a primary and any number of companions, and the two are treated differently on purpose: anything producing a list — diagnostics, completion, hover — merges across all of them; anything producing one answer — go to definition, rename, format — goes to the primary alone, because a companion would answer null and lose the real one.
- Diagnostics merge per session.
setDiagnosticsreplaces everything in a document, so two servers publishing independently each wiped the other's — whichever spoke last would have been the only one you ever saw. - Tailwind's configuration is served. It asks for
tailwindCSSandeditorthroughworkspace/configurationand offers nothing without them: no completions, no lint, no swatches. Another empty-{}answer that would have looked like our bug. - The cap went from 3 to 6. A React project with Tailwind is four servers before anything unusual happens, and a cap that refuses the ordinary case just looks broken.
-
servers.test.ts— 12 cases, including the two silent failures above and an invariant that no two primaries may claim one extension.
Both from docs/issues.
-
Tooltips no longer cover the breadcrumb. CodeMirror considers the whole window available when deciding where a tooltip fits, so a hover on line 1 flipped upward onto the breadcrumb bar — which is not part of the editor's DOM, so nothing stopped it and it hid the file path.
tooltips({ tooltipSpace })bounds them to the scroller, which makes them flip down instead, and keeps the completion list off the status bar at the other end. -
The same boundary for the LSP pickers. Floating UI was flipping and shifting against the viewport for the same reason; it now gets the scroller as its
boundary. -
The completion list is scannable. CodeMirror's kind icons are bare italic letters at 90% size and 0.6 opacity — in a list of forty Tailwind classes that is a column of grey specks. They are now filled, rounded badges with a colour per family, which is the entire job of an icon column.
-
Every completion kind is mapped. Nine of them fell through to
text, includingConstantandColor— which is almost the whole of a Tailwind class list, so that list rendered as undifferentiated plain text. -
The matched text stopped being underlined. It was told not to be, and the rule was losing: the library's styles are plain single-class selectors injected into the head, so which won depended on load order. Weight and colour instead of an underline, which also stops it colliding with the ⌘-hover link underline.
-
tooltipClassandoptionClass— a class on the dialog and one per row, so scoping is guaranteed rather than a specificity race, and the deprecated treatment stops reaching the label through a sibling selector on its icon. -
Built to the pickers' standard — bordered card, 14px kind badges, a 2px accent bar on the selected row, thin scrollbars, a bounded documentation panel using the same renderer as the hover tooltip, and a keyboard hint along the bottom.
No header, though, and that is the one place it deliberately departs from the pickers: a completion list opens on almost every keystroke, and a title bar on something that transient reads as heavy rather than considered. The footer is generated content because CodeMirror has no per-dialog slot — `addToOptions` injects per *option* — and a static hint loses nothing by not being real text. -
Light theme ordering — the neutral badge rule sat after the per-kind colours at equal specificity, which flattened every icon in the light theme back to grey.
csharp-ls installed cleanly and then refused to start:
You must install .NET to run this application.
App host version: 10.0.11
.NET location: Not found
On a machine where dotnet --list-runtimes printed a perfectly good
Microsoft.NETCore.App 10.0.11.
Two things compounding. A tool installed by dotnet tool install --global
is an apphost — a native launcher that must locate a runtime before it can
run anything. It checks DOTNET_ROOT, a couple of fixed system paths, and
/etc/dotnet/install_location. It does not look next to the dotnet on your
PATH. Homebrew installs to /opt/homebrew/opt/dotnet/libexec and sets no
DOTNET_ROOT; the dotnet CLI copes because it knows where it is, an apphost
does not. And /etc/dotnet/install_location_arm64 on this machine still named
/usr/local/share/dotnet from an official installer that is long gone — a
pointer that outlived what it pointed at.
- Derive
DOTNET_ROOTfrom thedotnetbinary we would actually run. Canonicalise it, then look forshared/Microsoft.NETCore.Appbeside it, under a siblinglibexec, or one level abovebin. That covers Homebrew, the official installer and a tarball unpacked anywhere, because in every layout the runtime sits beside the SDK. - Only when the user hasn't set it. Someone who pinned a runtime or pointed at a private build must not be second-guessed.
- Four unit tests over synthetic layouts, including the "no runtime nearby" case — which must yield nothing rather than a directory that would make the apphost fail more confusingly.
-
csharp-lsadded to the real-server handshake test, so a .NET apphost is exercised rather than only servers that need no runtime lookup. Five servers now complete the handshake on this machine.
This generalises: it fixes any dotnet tool, not just this one.
The transport was written to be portable — lsp/server.rs, framing.rs and
registry.rs carry no cfg branches at all, and spawn.rs already handled
PATHEXT and CREATE_NO_WINDOW because shell_exec needed them first. An
audit found one real gap and two smaller ones.
-
Job objects, so stopping a server kills the tree. The real gap, and the same one
terminal/pty.rsdocuments for shells. Every Node-based server installs its entry point as a.cmdshim, so the process we spawn on Windows iscmd.exeand the server is anode.exebelow it —TerminateProcesson the shim leaves an orphanedtypescript-language-serverbehind pipes nobody holds. Each server now gets a job object withKILL_ON_JOB_CLOSE, which also covers the case wherestopnever runs at all: a panic, or the app being killed outright.One job per server, not one for the app, so stopping one server does not take the others with it. `ProcessGroup` is a no-op off Windows, so `server.rs` still has no platform branches. -
UNC paths.
\\server\share\filewas becomingfile:////server/share/file— an empty host and a path beginning//server, which is a different file and one nothing can open. The server is the URI's authority, so it isfile://server/share/file, with two slashes. -
Per-platform install lines. Eight entries said
brew install, which reaches a Linux or Windows user as a confident instruction that cannot work.installOnoverrides per platform and the sharedinstallline is now always platform-neutral — with a test holding it that way, because that fallback is what everyone without an override sees. -
uri.test.ts— ten cases over the Windows shapes: drive letters get three slashes, the letter is uppercased (servers compare URIs as strings), spaces encode, UNC keeps its authority, and a non-file:scheme is refused rather than guessed at.
ring, via the updater plugin, needs a Windows C toolchain, so the crate
cannot be cross-checked as a whole — the same limitation
WINDOWS-TASKS.md records. The job-object module was
compiled and linted for x86_64-pc-windows-msvc in an isolated crate, which is
what caught JOBOBJECT_EXTENDED_LIMIT_INFORMATION being gated behind the
Win32_System_Threading feature rather than Win32_System_JobObjects.
That isolation has the blind spot the Windows port already paid for once: it checks the code this work wrote, not pre-existing code the Windows target newly compiles. A real build is still the authority.
- Manual, Windows — a Node-based server resolves through
PATHEXT, stops cleanly, and leaves nonode.exebehind in Task Manager. - Manual, Linux — a server starts under a GUI launch, which is where
the login-shell
PATHprobe earns its place.
- Semantic tokens — they would fight the Lezer highlighting, which is already fast, incremental and good.
- Inlay hints, call hierarchy, workspace symbols — each a feature of its own, and none is why somebody opened a terminal.
- Multi-root workspaces — the editor has one root at a time by design.
- Bundling or downloading servers — the user's toolchain is already installed and already configured.