Configuring Language Servers

Braide supports the Language Server Protocol (LSP) on a per-project basis. When a file in the embedded editor matches a configured server's globs, Braide spawns the server as a subprocess, brokers Monaco's provider calls (hover, go-to-definition, references, document symbols, workspace symbols) over stdio, and surfaces server-pushed diagnostics as Monaco markers (squiggles).

LSPs are configured per project — different projects can use different servers, different commands, even different versions of the same server, and they don't interfere with each other.

Overview

Each project can have any number of LSP entries. Each entry is a tuple of { name, command, args, fileGlobs, workspaceRoot, enabled } describing one server process. When an entry is enabled and a file in the editor matches one of its globs, the server starts (if it isn't already running) and Monaco's providers route through it.

The chip bar above the session view shows one chip per configured LSP, colour-coded by current status. Click a chip to open its action popover — Disable / Enable / Restart, and (when in error) Show error detail which surfaces the last 50 lines of the server's stderr — useful for diagnosing servers that fail to start or crash on initialize.

Chip statuses

StatusDot colourAnimationWhat it means
off (disabled)greystaticThe entry exists but is not running — either you toggled Enabled off in settings, or the project's LSPs have not been started yet.
starting…amberpulsingThe WebSocket bridge is open and the client is awaiting the server's initialize response.
readygreenstaticinitialize round-trip complete; Monaco providers are registered and diagnostics are wired.
indexing…bluepulsingThe server has signalled (via $/progress) that it is doing first-time work — parsing tsconfig.json, walking node_modules, building the project graph, etc. Hovers and go-to-definition may return incomplete results — including any for TypeScript types — until the chip turns green. For real-world TypeScript projects this can take several seconds on first launch.
reconnecting…amberpulsingThe WebSocket dropped after ready (network loss, sleep/wake, bridge restart) and the lifecycle is opening a fresh socket. The chip returns to ready if the reconnect succeeds, or moves to error if attempts are exhausted.
errorredstaticFailed before/while reaching ready, or reconnect attempts were exhausted. The popover gains a Show error detail item with the renderer-side failure message merged with the server's stderr tail.

The workspace symbol search modal (Cmd+Shift+F) queries every ready LSP for the project in parallel and merges results; an empty configured set surfaces the "No language servers configured" empty state.

Capability gating

LSP servers advertise their feature set in their initialize response. Braide silently skips registering Monaco providers for capabilities the server doesn't claim — there's no error, and other capabilities the server does claim still work. For example, pyright doesn't implement textDocument/typeDefinition; Braide notices that, doesn't register a TypeDefinitionProvider, and lets Monaco fall back to its default behaviour.

Diagnostics (squiggles) are server-pushed via textDocument/publishDiagnostics. There's no corresponding capability flag — Braide always subscribes; servers that don't publish diagnostics simply never wake the listener.

Adding an LSP entry

Open Settings → Language Servers for the active project. The empty state offers a New Language Server button; once you have one entry the same button moves into the section header.

The edit form has these fields:

  • Name — a human-readable label that appears on the status chip and in the search modal's row badges. Free-form (TypeScript, Rust, pyright).
  • Command — the executable to run. Resolved through PATH or used as an absolute path. On macOS desktop builds, prefer absolute paths — see Troubleshooting below.
  • Arguments — space- or comma-separated argv flags passed to the command. Most servers want --stdio (or omit args entirely if the server defaults to stdio).
  • File globs — one pattern per line. Files whose path matches any glob are routed to this server. Required.
  • Workspace root (optional) — path relative to the project root. Defaults to the project root when empty. Useful for monorepos where the LSP needs to run inside a sub-directory (e.g. apps/frontend for a TypeScript server scoped to that app).
  • Enabled — when off, the entry is kept in the config but the server is never spawned.

File-glob limitations

The glob matcher in v1 supports **, *, ?, and {a,b} brace alternation. Negation (!pattern) and character classes ([abc] / [a-z]) are not handled — they are treated as literal characters, so a glob like **/*.[ch] will silently fail to match any C / header file. If you need richer matching, list each extension explicitly: **/*.c and **/*.h on separate lines.

Common installations

The recipes below show one working configuration per language. Substitute paths to your own toolchain locations as needed.

TypeScript / JavaScript

Install:

npm install -g typescript-language-server typescript

Entry:

FieldValue
NameTypeScript
Commandtypescript-language-server
Arguments--stdio
File globs**/*.ts<br>**/*.tsx<br>**/*.js<br>**/*.jsx

When a TypeScript LSP attaches to a .ts / .js model, Braide automatically suppresses Monaco's bundled TypeScript worker for hovers, definitions, references, document symbols, signature help, rename, and completions to avoid duplicate UI from the two providers.

Rust

Install:

rustup component add rust-analyzer

Entry:

FieldValue
NameRust
Commandrust-analyzer
Arguments(none)
File globs**/*.rs

Go

Install:

go install golang.org/x/tools/gopls@latest

Entry:

FieldValue
NameGo
Commandgopls
Arguments(none)
File globs**/*.go

Python

Install:

npm install -g pyright

Entry:

FieldValue
NamePython
Commandpyright-langserver
Arguments--stdio
File globs**/*.py

Lua

Install (macOS):

brew install lua-language-server

Entry:

FieldValue
NameLua
Commandlua-language-server
Arguments(none)
File globs**/*.lua

Keyboard shortcuts

ShortcutAction
Cmd+Shift+F / Ctrl+Shift+FOpen the workspace symbol search modal
Cmd+Shift+O / Ctrl+Shift+OOpen the document (current-file) symbol picker — built into Monaco

Cmd+Shift+F follows the conventional "find across project" binding used by VS Code and JetBrains. Plain Cmd+F is left untouched so it remains the in-page find shortcut inside Monaco and the rest of the app — stealing it would surprise users who reach for find-in-file every minute. The Electron menu's Cmd+T is reserved for Toggle Terminal, so the renderer never sees a plain Cmd+T keydown anyway.

The two pickers are complementary: Cmd+Shift+O is fast and offline (Monaco walks the document symbols already cached for the current file); Cmd+Shift+F queries every ready LSP in parallel and surfaces matches across the workspace.

The workspace symbol search modal is also reachable from the session header toolbar — the magnifying-glass icon next to the file-browser and GitHub buttons opens the same modal as Cmd+Shift+F, so the action is discoverable without users having to know the keybinding.

Both activation paths require a session to be selected. Language servers are tied to the active session, so workspace symbol search is only meaningful while a session is open. Triggering the shortcut from the home/sessions list view surfaces an info-level toast that explains the dependency rather than opening an empty modal.

Troubleshooting

spawn ENOENT on the macOS desktop app

GUI applications launched on macOS do not inherit your shell's PATH. A server that resolves correctly in a terminal (because PATH includes Homebrew, asdf, nvm, fnm, etc.) will fail to spawn from the Braide desktop app with an ENOENT error.

Symptom: the chip flips to error immediately after enabling the entry. Clicking the chip and choosing Show error detail surfaces the line:

Command not found on PATH: '<command>'. Try using an absolute path (e.g. /usr/local/bin/<command>) — the desktop app does not inherit your shell PATH on macOS.

Fix: replace the bare command with its absolute path. Run which <command> in a terminal to find it (which typescript-language-server/Users/you/.nvm/versions/node/v25.0.0/bin/typescript-language-server), then paste that into the Command field.

Server starts but never reaches ready

The chip stays in starting for more than a few seconds. This almost always means the server is failing the initialize handshake — usually because it can't find its workspace root or a runtime dependency.

Click the chip, choose Show error detail (visible once the chip transitions to error), and read the last few stderr lines. Common causes:

  • pyright reports it can't find the active virtualenv — set the Workspace root to the directory containing pyproject.toml / setup.py.
  • rust-analyzer reports Cargo.toml not found — same fix, but for the Rust workspace.
  • typescript-language-server reports it can't find tsserver — install typescript alongside it (npm install -g typescript).

Hover shows any right after the server starts

You hover a typed value and the popup shows any (or the symbol's type is missing entirely), but the chip is green. Check the chip colour: if it's blue (indexing), the server is still loading the project graph and hover responses are incomplete by design. Wait for the chip to turn green before relying on type information.

If the chip is green and hovers still come back as any, the most likely causes are:

  • The file isn't part of the project the server resolved (e.g. it's outside the tsconfig.json include set). Check the Workspace root for the entry.
  • The server needs explicit configuration to find your project (e.g. pyright without a pyproject.toml will run in single-file mode). Restart the chip after fixing the config.

Diagnostics not appearing

The file is open, you've intentionally introduced an error, but no squiggle appears. Check, in order:

  1. The chip is ready (not off, starting, indexing, reconnecting, or error).
  2. The file's path matches at least one of the entry's File globs. Note the glob limitations above — [ch] / [a-z] character classes don't work.
  3. The server actually publishes diagnostics for this file's language. Some servers are extension-sensitive (pyright won't process .pyi unless explicitly globbed).
  4. The model's language id matches what the server expects. Monaco's auto-detection is reliable for common extensions but may misclassify unusual ones.

Memory growth with multiple project tabs

LSPs can be memory-hungry — rust-analyzer and tsserver regularly use 1–2 GB on real-world workspaces. With three project tabs open, each running a TypeScript and a Rust server, Braide's resident memory can reach 6–10 GB. This is the LSPs' usage, not the editor's; the desktop app and the renderer themselves stay in the low hundreds of megabytes.

If you hit memory pressure, close project tabs you're not actively using. Stopping a project tab tears down every LSP attached to it, freeing the subprocess memory immediately.

Known limitations

  • Glob patterns support **, *, ?, and {a,b}. Negation (!pattern) and character classes ([abc] / [a-z]) are not handled and silently mis-match.
  • Provider coverage in v1: hover, go-to-definition, go-to-type-definition, go-to-implementation, references, document symbols, workspace symbols, and diagnostics. Completion, signature help, rename, formatting, code actions, and code lenses are deferred to a future phase.
  • Cmd+T cannot be used as the workspace-symbol shortcut on the desktop app — Electron's menu accelerator (Toggle Terminal) preempts it. Braide uses Cmd+Shift+F instead, which also avoids stealing Monaco's plain Cmd+F in-page find.
  • Cross-file goto-definition follows the LSP response's targetUri even when it points outside the project worktree, but no special handling is provided for those out-of-tree paths beyond what Monaco's editor opener does by default.