# Red documentation (complete)

> Red is a modal text editor with Vim-style editing, language-server support, and extensible plugins.

This file joins every guide listed in the [documentation index](https://getred.dev/llms.txt). These pages document Red v0.9.1. Check red --version first and follow each guide's minimum version and tested-baseline notes.

# For coding agents

Canonical HTML: [For coding agents](https://getred.dev/docs/for-agents). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Choose version-appropriate instructions, configure language tools, build plugins, and validate changes in Red.

<a id="start-with-the-installed-version"></a>

## Start with the installed version

Use this page when helping a user install, configure, or extend Red. Red's built-in coding assistant has its own [Agent guide](https://getred.dev/docs/agent.md). Find documentation through [the documentation index](https://getred.dev/llms.txt); each guide is also available at its URL with `.md` appended.

```shell
red --version
red --help
```

This website's release metadata identifies **v0.9.1**. Compare that with the installed version and read each guide's minimum-version note before applying instructions. Multiple language servers per document and standalone JSON-RPC plugins require Red v0.9.0 or later. Follow [installation](https://getred.dev/docs/getting-started/install.md) and [update instructions](https://getred.dev/docs/getting-started/updating.md) for the user's installation method. `red update --check` checks availability without replacing the executable.

<a id="identify-the-configuration-layer"></a>

## Identify the configuration layer

| Layer                  | What belongs there                                                                                                                                                               |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Red user configuration | `$XDG_CONFIG_HOME/red/config.toml` when that variable is non-empty; otherwise `~/.config/red/config.toml`. Configure servers, formatters, keybindings, and plugin settings here. |
| Project files          | Keep `package.json`, `tsconfig.json`, `eslint.config.js`, `prettier.config.js`, and project dependencies in the workspace.                                                       |
| External executables   | Install language-server binaries and plugin interpreters separately. Embedded syntax support and a default server definition do not install the server executable.               |

Merge changes into existing TOML tables, then run `red --check-config`. A custom server table replaces its built-in entry as a unit, so preserve required selectors and arguments. See [configuration](https://getred.dev/docs/configuration.md) and [language support](https://getred.dev/docs/languages.md).

<a id="choose-the-interface"></a>

## Choose the interface

Use terminal commands for installation, package management, and configuration checks; use editor commands for interactive operations. For example, `red plugin list` runs in a terminal, while `:languages reload` and `:messages` run inside Red. Consult the [CLI reference](https://getred.dev/docs/reference/cli.md) and [session guide](https://getred.dev/docs/sessions.md).

Use the documented [JSON-RPC](https://getred.dev/docs/plugins/json-rpc.md) or [Husk](https://getred.dev/docs/husk.md) API to develop plugins. Red launches an RPC plugin as a child process and owns its editor resources. The protocol is not a general remote-control endpoint for arbitrary external clients and has no generic host-action escape hatch.

<a id="configure-typescript-eslint-and-prettier"></a>

## Configure TypeScript, ESLint, and Prettier

Follow the complete [TypeScript and ESLint guide](https://getred.dev/docs/languages/typescript-eslint.md), including its recorded compatibility baseline. Keep Red's built-in TypeScript server and register ESLint with overlapping document selectors. Install both server executables on `PATH`, and install ESLint, TypeScript, and Prettier in the project. ESLint also needs the guide's empty-section `workspace/configuration` settings.

Retain external Prettier formatting with `formatting.provider = "auto"` and a language formatter definition. Formatter lookup checks the project's `node_modules/.bin` before `PATH`; do not assume language-server startup uses that same lookup. Add formatter definitions for the other language keys if editing JSX or TSX.

Validate the config and restart Red after server-definition changes, then verify hover, completion, diagnostics from both servers, and an ESLint code action. With the guide's sample, changing quotes and pressing `Space f` should produce `formatted with Prettier`. Record the observed result, not just a successful config check.

<a id="create-and-validate-a-plugin"></a>

## Create and validate a plugin

Start from the complete TypeScript or Python package in the [JSON-RPC tutorial](https://getred.dev/docs/plugins/json-rpc.md), a Markdown outline and table of contents plugin. Red v0.9.0 uses host API `0.19.0` and protocol version `1`; these are distinct from Red's application version. Red v0.9.1 exposes host API `0.20.0` and retains support for `0.19.0` packages. Keep example manifests at the earliest API their features require. Use the tutorial's manifest and initialization response together. The TypeScript version needs Node.js 22.18 or later.

```shell
red plugin install --path ./markdown-outline
red plugin list
```

Open a Markdown file in Red, run `:Outline`, and confirm that the outline panel opens. Listing a package proves installation; invoking its command also exercises process startup and the handshake. If the command does not run, inspect `:messages` and `red.log` in Red's configuration directory. On Red v0.9.1 and later, use `:plugin-status` for the current session's diagnostic details.

For more operations, read the [v0.9.0 protocol reference](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/RPC_PLUGIN_PROTOCOL.md) and [complete JSON Workbench example](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/examples/rpc-json-workbench/README.md). Match those references to the user's release before adopting newer APIs.

<a id="diagnose-the-failing-stage"></a>

## Diagnose the failing stage

- **Executable:** a bare RPC command resolves on `PATH`; a command containing a path separator resolves inside the package. Arguments are passed without a shell or `~` expansion.
- **Manifest and handshake:** check API compatibility and ensure returned commands/events match the activation declarations exactly. Wait for `initialized` before calling host methods.
- **Protocol:** reserve stdout for newline-delimited JSON-RPC; log to stderr. Keep reading stdin while awaiting host responses.
- **Host method:** check names and parameters against the protocol. Invalid arguments can return an error while the connection stays active; framing corruption can quarantine the plugin.

On Red v0.9.1 and later, run `:plugin-status` to inspect the current editor session. Quarantined or starting plugins appear with diagnostic details; a separate `red plugin list` process cannot report that live state.

Read `:messages` and `red.log` in Red's configuration directory. Use `red --self-check` for bundled-runtime problems and `red --check-config` for configuration failures. Follow the [troubleshooting guide](https://getred.dev/docs/troubleshooting.md) and the relevant tutorial's failure table. Record the version, exact command, observed error, and smallest reproduction before proposing a fix.

# Use Red

Canonical HTML: [Use Red](https://getred.dev/docs). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Install the editor, complete a short first session, then move into the parts of Red you need.

<a id="start-here"></a>

## Start here

### Install Red

**macOS + Linux**

```shell
curl -fsS https://getred.dev/install.sh | sh
```

**Windows**

```powershell
irm https://getred.dev/install.ps1 | iex
```

**Homebrew**

```shell
brew install codersauce/tap/red
```

The install scripts verify the release checksum and run `red --self-check`. [Other install methods](https://getred.dev/docs/getting-started/install.md)

### Open a project

```shell
red -r path/to/project
```

Edit and save a file, then find commands when you need them. [Your first session](https://getred.dev/docs/getting-started/first-session.md)

<a id="agent-docs"></a>

## Point your agent at the docs

Every page is also plain Markdown. Start a coding agent at the index, or [add Red to its instructions](https://getred.dev/docs/getting-started/agent-docs.md) once. Agents that configure or extend Red should read [For coding agents](https://getred.dev/docs/for-agents.md).

- [`getred.dev/llms.txt`](https://getred.dev/llms.txt)
- [`getred.dev/docs.md`](https://getred.dev/docs.md)

<a id="browse-docs"></a>

## Browse by task

### Editor

Files, editing, search, windows, language tools, and Git.

- [Navigate and edit](https://getred.dev/docs/editor/navigation.md)
- [Search and replace](https://getred.dev/docs/editor/search.md)
- [Buffers and windows](https://getred.dev/docs/editor/windows.md)
- [Language tools](https://getred.dev/docs/languages.md)
- [TypeScript and ESLint](https://getred.dev/docs/languages/typescript-eslint.md)
- [Git](https://getred.dev/docs/git.md)

### Agent

Codex setup, full Agent work, inline assist, review, and history.

- [Agent and inline assist](https://getred.dev/docs/agent.md)

### Customize

Configuration, keybindings, themes, plugins, and Husk.

- [Configuration](https://getred.dev/docs/configuration.md)
- [Keybindings](https://getred.dev/docs/keybindings.md)
- [Themes](https://getred.dev/docs/themes.md)
- [Plugins](https://getred.dev/docs/plugins.md)
- [JSON-RPC plugins](https://getred.dev/docs/plugins/json-rpc.md)
- [Husk](https://getred.dev/docs/husk.md)

### Sessions and reference

Detach, recovery, command-line options, Vim behavior, and problem diagnosis.

- [Detach and recover](https://getred.dev/docs/sessions.md)
- [Command line](https://getred.dev/docs/reference/cli.md)
- [Vim compatibility](https://getred.dev/docs/reference/vim.md)
- [Troubleshooting](https://getred.dev/docs/troubleshooting.md)

# Install Red

Canonical HTML: [Install Red](https://getred.dev/docs/getting-started/install). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Install the Red binary for your platform, then check that its bundled runtime is ready.

<a id="choose-an-installation-method"></a>

## Choose an installation method

The install scripts support Apple Silicon and Intel Macs, x86-64 glibc Linux, and 64-bit x86-64 Windows. Homebrew is also available on macOS and Linux.

**macOS + Linux**

```shell
curl -fsS https://getred.dev/install.sh | sh
```

**Windows**

```powershell
irm https://getred.dev/install.ps1 | iex
```

**Homebrew**

```shell
brew install codersauce/tap/red
```

<a id="what-the-installer-does"></a>

## What the installer does

The shell and PowerShell installers download the release archive for your platform, compare it with the published SHA-256 checksum, install the binary, and run Red's self-check. They also enable managed updates for your standalone installation.

Already have Red? See [Update Red](https://getred.dev/docs/getting-started/updating.md) for manual updates and migrating an older installation.

- On macOS and Linux, the default destination is `~/.local/bin/red`.
- On Windows, the default destination is `%LOCALAPPDATA%\Programs\Red\bin\red.exe`. The installer adds that directory to your user PATH.

> **Linux support**
>
> The release archive targets x86-64 glibc Linux. Linux ARM64 and musl-based distributions currently require a source build.

<a id="check-the-installation"></a>

## Check the installation

Open a new terminal if the `red` command is not available immediately, then run:

```shell
red --version
red --self-check
```

The self-check lists the bundled plugins and finishes with `red self-check ok`. Agent support is optional and has its own setup check.

<a id="open-a-project"></a>

## Open a project

```shell
red -r path/to/project
```

Red works without a configuration file. The default configuration, plugins, and themes are embedded in the binary.

[Continue with your first session](https://getred.dev/docs/getting-started/first-session.md)

# Update Red

Canonical HTML: [Update Red](https://getred.dev/docs/getting-started/updating). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Check for new releases, update a standalone installation, and migrate an older install.

<a id="check-and-install-a-release"></a>

## Check and install a release

Official standalone installations support macOS Intel and Apple Silicon, x86-64 glibc Linux, and x86-64 Windows. Check for a newer stable release without changing your executable:

```shell
red update --check
```

Install the latest stable release:

```shell
red update
```

Inside the editor, run `:update` or choose **Update Red** in the command palette. You can continue editing while the update worker runs.

The updater verifies the archive's published SHA-256 checksum, candidate version, and bundled runtime before replacing the executable. Restart your editing sessions to use the new version. If a detached session is active, save and stop it before updating.

<a id="migrate-an-older-standalone-installation"></a>

## Migrate an older standalone installation

If you installed Red before v0.8.2, extracted an archive manually, or see an unmanaged-installation message, rerun the official installer once into your existing directory. It writes the installation receipt and lock file needed for managed updates. Save and close Red first, including detached sessions.

### macOS and Linux

Use your existing install directory. This example uses the default `~/.local/bin`:

```shell
curl --proto '=https' --tlsv1.2 -fsSL https://getred.dev/install.sh -o /tmp/red-install.sh
RED_INSTALL_DIR="$HOME/.local/bin" sh /tmp/red-install.sh
```

### Windows

Replace `-InstallDir` if you use a directory other than the default:

```powershell
$installer = Join-Path $env:TEMP "red-install.ps1"
Invoke-WebRequest https://getred.dev/install.ps1 -OutFile $installer
& $installer -InstallDir (Join-Path $env:LOCALAPPDATA "Programs\Red\bin")
```

Run `red --version` and `red update --check` to verify your installation.

<a id="homebrew-and-source-builds"></a>

## Homebrew and source builds

For Homebrew, run:

```shell
brew update
brew upgrade codersauce/tap/red
```

Source builds use your existing build and install process. The standalone updater refuses to replace executables it doesn't own.

<a id="disable-daily-checks"></a>

## Disable daily checks

Red checks for a newer stable release once per day by default and only notifies you. Installation requires your command. To disable automatic checks, add this to `~/.config/red/config.toml` and restart Red:

```toml
[updates]
mode = "off"
```

`red update --check`, `red update`, and `:update` remain available.

[Continue with your first session](https://getred.dev/docs/getting-started/first-session.md)

# Your first session

Canonical HTML: [Your first session](https://getred.dev/docs/getting-started/first-session). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Open a project, edit and save a file, and learn where Red keeps its commands and shortcuts.

<a id="start-red"></a>

## Start Red

Open a file, or set the workspace root explicitly:

```shell
red src/main.rs
red -r path/to/project
```

The first interactive launch opens Red's welcome screen. Press `Esc` to enter the editor, or start the guided tour. The tour uses a disposable practice buffer and simulated Git and Agent screens; it doesn't change your repository or start Codex.

```red-command
:tutorial
:tutorial quick
```

<a id="edit-and-save-a-file"></a>

## Edit and save a file

1. **Find a file.**

   Press `Ctrl-p`, type part of its name, and press `Enter`.

2. **Enter Insert mode.**

   Press `i` and type your change. Press `Esc` to return to Normal mode.

3. **Save.**

   Type `:w` and press `Enter`.

4. **Leave Red.**

   Type `:q` and press `Enter`. Use `:wq` to save and quit in one command.

<a id="know-which-mode-you-are-in"></a>

## Know which mode you are in

| Mode    | Enter with            | Use it for                      |
| ------- | --------------------- | ------------------------------- |
| Normal  | `Esc`                 | Navigation and editing commands |
| Insert  | `i`, `a`, or `o`      | Typing text                     |
| Visual  | `v`, `V`, or `Ctrl-v` | Selecting text                  |
| Command | `:`                   | Named editor commands           |

<a id="find-the-next-command"></a>

## Find the next command

Press `Space ?` to open the command palette. It shows available commands, descriptions, and active keybindings. Press `F1` for keyboard help in the current editor, pane, or dialog.

`Ctrl-p`Find a project file

`Ctrl-e`Open the file tree

`Space g`Search project text

`Space G`Open the Git workspace

`Space t`Browse themes

`Space m`Open messages

# Configure Red

Canonical HTML: [Configure Red](https://getred.dev/docs/configuration). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Keep the embedded defaults and add only the settings you want to change.

<a id="start-with-a-small-file"></a>

## Start with a small file

Red works without a configuration file. On macOS and Linux, create `~/.config/red/config.toml` when you want to override a default. The welcome screen can create a starter file without replacing existing settings.

```toml
theme = "red.json"
scrolloff = 8

[search]
ignorecase = true
smartcase = true

[keys.normal]
"Ctrl-s" = "Save"
```

Your file is layered over Red's embedded configuration, so it doesn't need to repeat unchanged settings.

<a id="check-a-change"></a>

## Check a change

```shell
red --check-config
red -c 'wrap = false' path/to/file
```

`--check-config` validates the effective user configuration and exits. The repeatable `-c` option applies a TOML override for one invocation.

<a id="common-sections"></a>

## Common sections

| Section              | Controls                                                                                        |
| -------------------- | ----------------------------------------------------------------------------------------------- |
| Top level            | Theme, wrapping, scrolling, line numbers, release notes, diagnostics, and disabled plugins      |
| `[search]`           | Incremental search, highlighting, wrapping, and case behavior                                   |
| `[completion]`       | Automatic completion, buffer words, prefix length, and menu timing                              |
| `[agent]`            | Codex command, inline review behavior, followed tool calls, and explicitly enabled integrations |
| `[keys.MODE]`        | Mode-specific keybindings and chords                                                            |
| `[languages.ID]`     | Detection, comments, indentation, grammar, and language-server settings                         |
| `[plugin_config.ID]` | Settings owned by an enabled plugin                                                             |

[Open the commented default configuration](https://github.com/codersauce/red/blob/main/default_config.toml)

# Language tools

Canonical HTML: [Language tools](https://getred.dev/docs/languages). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Use completion, diagnostics, navigation, formatting, and language packs for the files in your workspace.

<a id="use-a-language-server"></a>

## Use a language server

Red starts a configured server after you open a matching file. The built-in defaults cover Rust, Fish, Markdown, JavaScript and TypeScript, JSON, TOML, YAML, and Lua; those server executables must be installed separately and available on `PATH`. Red includes its own server for `.hk` and `.husk` files.

`K`Show hover documentation

`gd`Go to definition

`Ctrl-Space`Request completion

`Ctrl-k`Show signature help

`Ctrl-t`Find document symbols

`Space w`Find workspace symbols

`Space k`Find references

`Space .`Show code actions

`Space r`Rename a symbol

`Space f`Format the current document

<a id="completion-and-formatting"></a>

## Completion and formatting

Completion combines words from open buffers with candidates from the active language server. Use the arrow keys or `Ctrl-n`/`Ctrl-p` to choose an item, `Tab` or `Enter` to accept it, and `Ctrl-e` to close the menu.

Red formats supported documents when saving and reindents pasted code by default. Disable either behavior without disabling explicit formatting:

```toml
[formatting]
on_save = false
on_paste = false
```

When format-on-save is pending, `:wq` waits for the save before exiting. Red stays open if the save fails or the buffer changes during formatting.

<a id="add-or-override-a-server"></a>

## Add or override a server

```toml
[lsp.servers.go]
command = "gopls"
language_id = "go"
file_extensions = ["go"]
root_markers = ["go.mod", ".git"]
```

Restart Red after changing a server definition, or run `:languages reload` after changing a unified language definition.

Starting with **v0.9.0**, multiple configured servers with overlapping file extensions or filenames attach to the same buffer automatically, combining diagnostics and code actions while routing completion, hover, and navigation by server capability. [Configure TypeScript and ESLint together](https://getred.dev/docs/languages/typescript-eslint.md)

<a id="install-a-language-pack"></a>

## Install a language pack

Open **Language packs** from the command palette, or use the CLI:

```shell
red plugin catalog
red plugin install --catalog go-language
red plugin install --catalog python-language --trust-native-grammars
```

Native Tree-sitter grammars execute inside the editor. Installing a pack doesn't approve them automatically; trust only the exact verified grammar bytes you intend to load.

```shell
red language trust LANGUAGE_ID
red language untrust LANGUAGE_ID
```

# TypeScript and ESLint (multiple language servers)

Canonical HTML: [TypeScript and ESLint (multiple language servers)](https://getred.dev/docs/languages/typescript-eslint). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Attach TypeScript and ESLint language servers to the same buffer, keep Prettier formatting, and troubleshoot multi-server setups.

<a id="goal-and-required-red-version"></a>

## Goal and required Red version

> **Available in Red v0.9.0 and later**
>
> Use **Red v0.9.0 or later** to attach multiple language servers to a single document. In Red v0.8.2 and earlier, only one matching server attaches to each file.

This guide configures `typescript-language-server` and `vscode-eslint-language-server` to work together in the same `.ts`, `.tsx`, `.js`, or `.jsx` buffer while keeping Prettier as the external formatter. TypeScript serves completion, hover, signature help, symbols, and go-to-definition; ESLint publishes lint diagnostics and rule code actions alongside TypeScript's own diagnostics and quick fixes.

| Layer                  | Where it lives                                                    | Responsibility                                                                                                                           |
| ---------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Embedded in Red        | Red binary                                                        | Tree-sitter syntax support for JS, JSX, TS, and TSX, plus a default `typescript` server definition                                       |
| Server executables     | Your `PATH`                                                       | `typescript-language-server` and `vscode-eslint-language-server` installed separately from Red                                           |
| Red user configuration | `~/.config/red/config.toml` or `$XDG_CONFIG_HOME/red/config.toml` | ESLint server registration, ESLint `workspace/configuration` settings, and optional external formatter definitions                       |
| Project workspace      | Project directory                                                 | `package.json`, local `node_modules` (`eslint`, `typescript`, `prettier`), `tsconfig.json`, `eslint.config.js`, and `prettier.config.js` |

Red does not automatically discover or register ESLint from a project's `eslint.config.js` or `package.json`. Once matching servers are configured in `config.toml`, Red attaches all of them automatically without an extra multi-LSP flag. Each server's `root_markers` only select the workspace root directory passed to that server; they do not install or register a server.

<a id="install-the-required-tools"></a>

## Install the required tools

Keep language-server binaries on `PATH` separate from the packages installed inside your project's `node_modules`.

### 1. Install the language-server executables on PATH

Install `typescript-language-server`, `typescript`, and `vscode-langservers-extracted` (which provides `vscode-eslint-language-server`):

```shell
npm install -g typescript-language-server@6.0.1 typescript@6.0.3 vscode-langservers-extracted@4.8.0
which typescript-language-server
which vscode-eslint-language-server
typescript-language-server --version
```

Installing `typescript` alone provides `tsc` and `tsserver`, not the `typescript-language-server` executable that Red launches.

### 2. Install project dependencies

`vscode-eslint-language-server` loads ESLint from the project's local `node_modules`, and `typescript-language-server` uses the workspace TypeScript compiler when present. Inside your project directory, install the compiler, linter, and formatter:

```shell
npm install --save-dev eslint@10.4.1 @eslint/js@10.0.1 typescript-eslint@8.60.1 typescript@6.0.3 prettier@3.8.3
```

> **Tested compatibility baseline**
>
> The steps in this guide were verified against the v0.9.0 implementation (`bf2c2039d`) on Node.js `v24.22.0` with `vscode-langservers-extracted` `4.8.0`, `typescript-language-server` `6.0.1`, TypeScript `6.0.3`, ESLint `10.4.1`, `@eslint/js` `10.0.1`, `typescript-eslint` `8.60.1`, and Prettier `3.8.3`. Other recent versions that use stdio LSP and ESLint flat configs follow the same setup, though diagnostic text and rule actions can vary across ESLint and plugin releases.

<a id="create-a-small-typescript-eslint-project"></a>

## Create a small TypeScript + ESLint project

If you want a self-contained project to verify the setup before opening a larger repository, create a directory with the following files and install the devDependencies above.

`package.json`:

```json
{
  "name": "red-ts-eslint-demo",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@eslint/js": "10.0.1",
    "eslint": "10.4.1",
    "prettier": "3.8.3",
    "typescript": "6.0.3",
    "typescript-eslint": "8.60.1"
  }
}
```

`tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUnusedLocals": true
  },
  "include": ["src"]
}
```

`eslint.config.js`:

```javascript
import js from '@eslint/js';
import { defineConfig } from 'eslint/config';
import ts from 'typescript-eslint';

export default defineConfig(
  js.configs.recommended,
  ts.configs.recommended,
);
```

`prettier.config.js`:

```javascript
export default {
  singleQuote: true,
  semi: true,
};
```

`src/index.ts`:

```typescript
export const test = 'variable test';
const unusedTest = 'unused variable test';
```

Confirm that ESLint reports `@typescript-eslint/no-unused-vars` on `src/index.ts` from the terminal:

```shell
npx eslint src/index.ts
```

<a id="configure-red"></a>

## Configure Red

Red reads its user configuration from `$XDG_CONFIG_HOME/red/config.toml` when `XDG_CONFIG_HOME` is set and non-empty, or `~/.config/red/config.toml` otherwise. Because Red already embeds a default `typescript` server definition for `.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, and `.jsx`, the primary setup keeps that built-in TypeScript server and adds ESLint plus Prettier:

```toml
[formatting]
provider = "auto"

[languages.typescript.formatter]
name = "Prettier"
command = "prettier"
args = ["--stdin-filepath", "{file}"]
root_markers = ["prettier.config.js", "package.json", ".git"]

[lsp.servers.eslint]
command = "vscode-eslint-language-server"
args = ["--stdio"]
documents = [
  { language_id = "javascript", file_extensions = ["js", "mjs", "cjs"] },
  { language_id = "javascriptreact", file_extensions = ["jsx"] },
  { language_id = "typescript", file_extensions = ["ts"] },
  { language_id = "typescriptreact", file_extensions = ["tsx"] },
]
root_markers = ["eslint.config.js", "package.json", ".git"]

[lsp.servers.eslint.settings.""]
validate = "on"
run = "onType"
packageManager = "npm"
workingDirectory = { mode = "auto" }
nodePath = ""
useESLintClass = false
quiet = false
options = {}
experimental = {}
format = false
onIgnoredFiles = "off"
rulesCustomizations = []
codeActionOnSave = { mode = "all" }
problems = { shortenToSingleLine = false }

[lsp.servers.eslint.settings."".codeAction]
disableRuleComment = { enable = true, location = "separateLine", commentStyle = "line" }
showDocumentation = { enable = true }
```

The `[lsp.servers.eslint.settings.""]` and `[lsp.servers.eslint.settings."".codeAction]` tables are required by `vscode-eslint-language-server`. After initialization, the server sends a `workspace/configuration` request with `section = ""`; Red returns the contents of `settings.""`. Without that table, Red replies with `null` and the ESLint server does not validate buffers or offer rule-disable code actions.

### Merge into an existing config.toml safely

- **Avoid duplicate TOML table headers.** If your `config.toml` already has `[formatting]`, `[languages.typescript.formatter]`, or `[lsp.servers.eslint]`, merge the keys into the existing table instead of adding a second header with the same name.
- **Keep the built-in TypeScript server unless you need to customize it.** Omit `[lsp.servers.typescript]` so Red uses its embedded definition. Writing a partial `[lsp.servers.typescript]` table replaces the built-in entry as a unit and drops any omitted fields or extensions.

### Advanced: override the built-in TypeScript server

If you need to customize `typescript-language-server` arguments, environment variables, or root markers, include the complete definition so you preserve Red's default selectors (including `.mjs` and `.cjs`) and root markers:

```toml
[lsp.servers.typescript]
command = "typescript-language-server"
args = ["--stdio"]
documents = [
  { language_id = "javascript", file_extensions = ["js", "mjs", "cjs"] },
  { language_id = "javascriptreact", file_extensions = ["jsx"] },
  { language_id = "typescript", file_extensions = ["ts"] },
  { language_id = "typescriptreact", file_extensions = ["tsx"] },
]
root_markers = ["package.json", "tsconfig.json", "jsconfig.json", ".git"]
```

Validate your configuration from the terminal:

```shell
red --check-config
```

Restart Red after changing server definitions or installing server executables. For changes limited to unified language definitions, such as external formatters, you can reload those definitions inside Red:

```red-command
:languages reload
```

<a id="use-the-right-language-ids-and-selectors"></a>

## Use the right language IDs and selectors

Red uses two distinct sets of identifiers for JavaScript and TypeScript files: LSP `language_id` values sent to language servers in `textDocument/didOpen`, and Red's own `[languages.ID]` table names used for syntax and external formatters.

| Extension             | LSP `documents` `language_id` | Red `[languages.ID]` key |
| --------------------- | ----------------------------- | ------------------------ |
| `.js`, `.mjs`, `.cjs` | `javascript`                  | `javascript`             |
| `.jsx`                | `javascriptreact`             | `jsx`                    |
| `.ts`                 | `typescript`                  | `typescript`             |
| `.tsx`                | `typescriptreact`             | `tsx`                    |

Keep separate entries in `documents` for each extension rather than mapping `language_id = "typescriptreact"` to `["js", "jsx", "ts", "tsx"]`. Each attached server receives the `language_id` from the matching selector entry when a file opens.

If you also format `.tsx`, `.js`, and `.jsx` files with Prettier, add matching formatter tables under Red's language keys (`[languages.tsx.formatter]`, `[languages.javascript.formatter]`, and `[languages.jsx.formatter]`) using the same Prettier command and arguments.

<a id="how-red-routes-requests-across-attached-servers"></a>

## How Red routes requests across attached servers

- **Attachment and lifecycle:** Every configured server whose `documents` selector matches the file attaches to the buffer. Each attached server receives open, change, save, and close notifications with its own `language_id`, workspace root, and document version counter.
- **Diagnostics:** Push and pull diagnostics remain scoped to the server that produced them and are combined for display. An empty diagnostic report, server reload, or crash clears only that server's diagnostics.
- **Code actions:** Pressing `Space .` or `Space c a` queries every attached server that advertises `codeActionProvider`, sending each server only the diagnostics it owns. Red merges the returned actions into one picker and routes the selected edit or command back to its originating server.
- **Single-result requests:** Completion, hover, go-to-definition, references, document and workspace symbols, signature help, inlay hints, and rename go to the first ready attached server that advertises the required capability. When multiple attached servers advertise the same capability, Red uses the server configuration name (`[lsp.servers.NAME]`) in alphabetical order as the tie-breaker. Because `eslint` does not advertise hover, completion, definition, or symbol providers, those requests go to `typescript` even though `eslint` sorts first alphabetically.

<a id="verify-typescript-and-eslint-in-the-same-buffer"></a>

## Verify TypeScript and ESLint in the same buffer

1. **Open the file.**

   From the project directory, run `red -r . src/index.ts`.

2. **Check TypeScript hover.**

   Move to `test` on line 1 (`w` `w`) and press `K`. Red displays the hover popup from `typescript-language-server` (`const test: "variable test"`). Press `Esc` to close the popup.

3. **Check TypeScript completion.**

   Press `G` then `o` to open a new line, and type `test.` (or press `Ctrl-Space`). String members from `typescript-language-server` such as `slice`, `trim`, and `length` appear in the completion menu. Press `Esc` and `u` to undo the test line.

4. **Check combined diagnostics.**

   Move to `unusedTest` on line 2 (`gg` `j` `w`) and press `D` (or press `Space d` to open the workspace diagnostics picker). Both diagnostics appear for `unusedTest`: ESLint's `'unusedTest' is assigned a value but never used.` (`@typescript-eslint/no-unused-vars`) and TypeScript's `'unusedTest' is declared but its value is never read.` (`6133`).

<a id="apply-an-eslint-code-action"></a>

## Apply an ESLint code action

With the cursor on `unusedTest` on line 2, press `Space .` or `Space c a`. The code-action picker lists actions from both attached servers, including ESLint's rule actions and TypeScript's quick fixes.

Choose **Disable @typescript-eslint/no-unused-vars for this line** and press `Enter`. Red applies the workspace edit from `vscode-eslint-language-server`:

```typescript
export const test = 'variable test';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const unusedTest = 'unused variable test';
```

ESLint's `@typescript-eslint/no-unused-vars` error clears immediately, while TypeScript's own unused-variable diagnostic on `unusedTest` remains until the variable is read or removed.

<a id="keep-prettier-formatting"></a>

## Keep Prettier formatting

External formatters remain independent of language-server attachments. With `formatting.provider = "auto"` (the default) and `[languages.typescript.formatter]` configured for Prettier, pressing `Space f` runs Prettier instead of formatting through either language server. Saving with `:w` also formats when `formatting.on_save = true`; preserve the user's existing preference.

Red looks for `prettier` in the project's `node_modules/.bin` before searching `PATH`, and passes the current buffer over stdin with `--stdin-filepath {file}`. Setting `format = false` under `[lsp.servers.eslint.settings.""]` also prevents ESLint from advertising LSP formatting if Red ever falls back to LSP formatting.

To verify, change single quotes to double quotes on line 1 (`export const test = "variable test";`) and press `Space f`. Red rewrites the line to single quotes and reports `formatted with Prettier`.

<a id="troubleshoot-common-setup-failures"></a>

## Troubleshoot common setup failures

| Symptom                                                                  | Cause and fix                                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| TypeScript works, but ESLint diagnostics never appear                    | Confirm `which vscode-eslint-language-server` succeeds on `PATH` (note that `~` is not expanded in `command`). When ESLint cannot start, Red isolates the failure so TypeScript completion, hover, and diagnostics keep working; check `Space m` (`:messages`) and `red.log` in your Red configuration directory. After installing a previously missing executable, restart Red so the cached startup failure is cleared. |
| `red --check-config` reports an error or LSP is disabled                 | Duplicate TOML headers such as two `[lsp.servers.eslint]` tables make the file malformed (`CFG002`) and start Red in its fail-closed fallback profile with LSP disabled. An invalid field inside one server table reports `CFG102` and disables only that server. Fix the reported line and re-run `red --check-config`.                                                                                                  |
| `.tsx` or `.jsx` files miss diagnostics, or `.ts` files parse as JSX     | Check the `documents` array on both servers. Map `.ts` to `typescript`, `.tsx` to `typescriptreact`, `.js`/`.mjs`/`.cjs` to `javascript`, and `.jsx` to `javascriptreact`.                                                                                                                                                                                                                                                |
| ESLint server starts, but reports no diagnostics or code actions         | Run `npx eslint src/index.ts` in the project root to verify local `node_modules/eslint` and `eslint.config.js` outside Red. Then confirm `[lsp.servers.eslint.settings.""]` and `[lsp.servers.eslint.settings."".codeAction]` are present in `config.toml` so `workspace/configuration` does not return `null`.                                                                                                           |
| TypeScript completion and hover stop working after editing `config.toml` | A `[lsp.servers.typescript]` table in `config.toml` replaces Red's embedded `typescript` server entry in full, and a `[languages.typescript.lsp]` block without `server = "eslint"` also writes to the `typescript` server key. Remove your custom `[lsp.servers.typescript]` block to restore Red's built-in default, or use the complete override shown in the advanced section above.                                  |

<a id="use-the-same-pattern-for-other-language-servers"></a>

## Use the same pattern for other language servers

You can attach any combination of language servers to the same file type by giving each server its own `[lsp.servers.NAME]` table with overlapping `file_extensions` or `filenames` selectors:

- Diagnostics from all attached servers merge automatically while keeping per-server ownership.
- Code actions aggregate across every attached server that advertises `codeActionProvider`.
- Single-result requests go to the first ready attached server that advertises the capability, using the server key name (`NAME`) in alphabetical order as a tie-breaker when more than one server supports the same request. Red does not guess a universally “best” server beyond capability checks and configuration-name order, so name server keys intentionally or disable overlapping capabilities in a secondary server's settings when two servers advertise the same single-result feature.

[Return to Language tools](https://getred.dev/docs/languages.md)

# Change keybindings

Canonical HTML: [Change keybindings](https://getred.dev/docs/keybindings). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Map actions, action sequences, chords, and plugin commands without changing how keys behave in other modes.

<a id="find-the-current-binding-first"></a>

## Find the current binding first

Press `F1` for keyboard help in the current context, or `Space ?` for the command palette. Both surfaces show effective bindings after your overrides are applied.

<a id="map-an-action"></a>

## Map an action

```toml
[keys.normal]
"Ctrl-s" = "Save"
"u" = "Undo"

[keys.insert]
"Ctrl-s" = "Save"
```

Each mode has its own table. A Normal-mode mapping doesn't change what the same key types in Insert mode.

<a id="use-sequences-chords-and-plugin-commands"></a>

## Use sequences, chords, and plugin commands

```toml
[keys.normal]
"a" = [{ EnterMode = "Insert" }, "MoveRight"]
"g" = { "d" = "GoToDefinition" }
"Ctrl-j" = { PluginCommand = "BufferPicker" }
```

Normal-mode bindings are inherited by Visual modes unless you add an explicit binding under `[keys.visual]`. Red rejects ambiguous plugin leader mappings instead of silently discarding one.

<a id="control-prefix-hints"></a>

## Control prefix hints

```toml
[key_hints]
enabled = true
delay_ms = 250
```

Pause after a configured prefix such as `Space`, `Ctrl-w`, or `g` to see the available continuations.

<a id="diagnose-terminal-shortcuts"></a>

## Diagnose terminal shortcuts

```shell
red keys
```

The diagnostic shows how the current terminal reports key combinations without opening a document or starting Codex. Use `Ctrl+J` as the portable newline in Agent composers when a terminal doesn't distinguish modified Enter keys.

# Choose and edit themes

Canonical HTML: [Choose and edit themes](https://getred.dev/docs/themes). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Preview bundled themes, keep a selection, or eject a JSON theme into your configuration directory.

<a id="browse-themes"></a>

## Browse themes

Press `Space t` or choose **Browse themes** from the command palette. Moving through the picker previews each theme. `Esc` restores the original; `Enter` selects and persists the highlighted theme.

<a id="set-a-theme-directly"></a>

## Set a theme directly

```toml
theme = "red.json"
```

Add the filename to `config.toml`. Run `red --runtime-files` to list every visible theme and its source.

<a id="customize-a-bundled-theme"></a>

## Customize a bundled theme

```shell
red --eject themes/red.json
red --runtime-files
```

The ejected file is placed in the `themes` directory under Red's configuration directory. A user file shadows the bundled theme with the same name until you remove it. Use `--eject-force` only when you intend to replace an existing custom copy.

# Manage plugins

Canonical HTML: [Manage plugins](https://getred.dev/docs/plugins). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Inspect bundled plugins, install external packages, control their lifecycle, and keep native grammar approval explicit.

<a id="inspect-what-red-loaded"></a>

## Inspect what Red loaded

```shell
red --runtime-files
red --self-check
red plugin list
```

`--runtime-files` shows visible plugins and themes with their source. `--self-check` reports the activation status of bundled plugins. `red plugin list` lists installed external packages.

<a id="inspect-plugins-in-the-current-session"></a>

## Inspect plugins in the current session

In Red v0.9.1 and later, run `:plugin-status` or choose **Plugin status** from the command palette. The picker shows configured plugins as active, starting, loads on first use, disabled, or quarantined, and identifies rejected reloads. Problems appear first; the preview includes the full reason and recovery guidance.

Use this view when a plugin command is unavailable or an RPC plugin takes time to initialize. Startup notices explain which command is waiting, and executable failures include the underlying cause. `red plugin list` runs in a separate process and does not report the running editor's session state.

<a id="install-a-package"></a>

## Install a package

```shell
red plugin catalog
red plugin install --catalog go-language
red plugin install owner/repository
red plugin install --path ~/code/my-red-plugin
```

Catalog packages use immutable release archives with recorded checksums. GitHub and local-path sources remain explicitly unreviewed. Installing a package never grants implicit approval to execute a native Tree-sitter grammar.

<a id="run-plugins-written-in-other-languages"></a>

## Run plugins written in other languages

Starting with Red v0.9.0, external plugins can run as JSON-RPC processes over stdio. They can provide commands, panels, pickers, statusline content, and checked document edits. Install and manage these packages with the same `red plugin` commands as Husk packages; install any interpreter or executable required by the package separately.

[Read the JSON-RPC plugin protocol](https://github.com/codersauce/red/blob/v0.9.0/docs/RPC_PLUGIN_PROTOCOL.md)

<a id="try-json-workbench"></a>

## Try JSON Workbench

JSON Workbench is an example JSON-RPC plugin for live jq previews, result exploration, and undoable transformations. It requires Python 3.9 or newer and `jq` on `PATH`. Install it separately from the versioned Red examples:

```shell
git clone --branch v0.9.0 --depth 1 https://github.com/codersauce/red.git red-plugin-examples
red plugin install --path ./red-plugin-examples/examples/rpc-json-workbench
```

Restart Red, open a JSON file, and run `:JsonWorkbench`. Edit the query document, run `:JsonRun`, or enable `:JsonLive` for previews as you edit. Use `:JsonApply` to apply a successful result, `:JsonUndo` to restore the source, and `:JsonCancel` to stop a live or panel preview. Foreground `:JsonRun` and `:JsonQuery` commands finish or reach their query timeout before another command can run in that plugin.

[See JSON Workbench commands and configuration](https://github.com/codersauce/red/blob/v0.9.0/examples/rpc-json-workbench/README.md)

<a id="enable-update-or-remove-a-package"></a>

## Enable, update, or remove a package

```shell
red plugin disable replay
red plugin enable replay
red plugin update replay
red plugin update --all
red plugin remove replay
red plugin remove replay --purge
```

Ordinary removal preserves the plugin's namespaced data for a later reinstall. `--purge` deletes that data as well. Failed updates leave the previous installation active.

<a id="customize-a-bundled-plugin"></a>

## Customize a bundled plugin

```shell
red --eject plugins/fidget.hk
```

The copy in your configuration directory shadows the bundled file. Remove the copy to return to the version shipped with Red.

Husk plugins that spawn processes through the host API need an explicit allowlist:

```toml
[plugin_permissions.project_search]
process = ["rg"]
```

Starting with **v0.9.0**, external packages can also declare a standalone `[rpc]` process in `red-plugin.toml` and talk to Red over newline-delimited JSON-RPC 2.0 in any language. [Write a JSON-RPC plugin in TypeScript or Python](https://getred.dev/docs/plugins/json-rpc.md)

# Write a JSON-RPC plugin

Canonical HTML: [Write a JSON-RPC plugin](https://getred.dev/docs/plugins/json-rpc). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Build a live Markdown outline and table of contents plugin in TypeScript or Python with Red's JSON-RPC host API.

<a id="what-youll-build"></a>

## What you'll build

> **Available in Red v0.9.0 and later**
>
> Use **Red v0.9.0 or later** for standalone JSON-RPC plugins and the `0.19.0` host API surface. They are not included in Red v0.8.2.

This guide builds **Markdown Outline**, a plugin that shows a live outline of the Markdown file you're editing and keeps its table of contents up to date. Along the way it uses most of Red's host API: document snapshots and watches, text panels with buttons, transactional edits and undo, the statusline, the clipboard, pickers, configuration, storage, and editor events.

```text
README.md                      │ Outline  [r TOC] [c Copy] [u Undo] [x]
# Bucket                       │ • Bucket
                               │   • Install
A token bucket for Rust.       │   • Usage
                               │     • Configure
<!-- toc -->                   │   • License
- [Install](#install)          │
- [Usage](#usage)              │
  - [Configure](#configure)    │
- [License](#license)          │
<!-- tocstop -->               │
                               │
## Install                     │
NORMAL  README.md                               § 5 headings · 39 words
```

A JSON-RPC plugin is a package with a `red-plugin.toml` manifest and a program that Red starts as a child process. They talk in JSON-RPC 2.0 over standard input and output. Red keeps ownership of buffers, undo history, focus, panels, and sessions; your process asks for each operation through a versioned `red/*` host method.

| Rule                    | Protocol version 1 behavior                                                                                                                                                                                             |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Framing                 | One compact UTF-8 JSON object per line (`\n`) on `stdin` and `stdout`. Do **not** use LSP `Content-Length` headers or JSON-RPC batch arrays.                                                                            |
| Standard output         | Reserved strictly for JSON-RPC messages. Write logs and diagnostics to `stderr`; Red retains the last 16 KiB of `stderr` when reporting a failure.                                                                      |
| Host API & protocol     | Declare `red_api = "^0.19.0"` in `red-plugin.toml` and return `"protocol_version": 1` from `initialize`.                                                                                                                |
| Handshake agreement     | `initialize` must return the exact commands and events declared under `[activation]` in `red-plugin.toml`, with no missing or extra entries. Wait for the `initialized` notification before calling any `red/*` method. |
| Identifiers & revisions | Document revisions, `document_id`, and `transaction_id` are **decimal strings** on the wire so JavaScript does not lose precision above `Number.MAX_SAFE_INTEGER`. Keep them as strings.                                |
| Text coordinates        | Zero-based lines and **Unicode-scalar** character offsets (half-open ranges), neither UTF-8 byte offsets nor JavaScript UTF-16 code units.                                                                              |

Every step shows **TypeScript** and **Python**. The TypeScript version runs directly with `node plugin.ts`: Node.js 22.18 and later strip the types when loading the file, so there is no build step and no dependency to install. The Python version needs Python 3.9 or later and uses only the standard library. Pick a language once; the tabs stay in sync.

<a id="1-create-the-package-and-a-first-command"></a>

## 1. Create the package and a first command

A package is a directory with `red-plugin.toml` and your program. Red runs `[rpc].command` with the package directory as its working directory and passes `args` directly, without a shell. Start with one command that reads the current document and counts its headings:

**TypeScript**

```text
markdown-outline/
├── red-plugin.toml
└── plugin.ts
```

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "node"
args = ["plugin.ts"]

[activation]
commands = ["Outline"]
```

`markdown-outline/plugin.ts`:

```typescript
#!/usr/bin/env node
import { createInterface } from "node:readline";

const send = (message: Record<string, unknown>) =>
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);

let nextRequest = 1;
const waiting = new Map<string, (reply: any) => void>();

// Send a request to Red and resolve with its response.
function callHost(method: string, params = {}): Promise<any> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => {
    waiting.set(id, (reply) => {
      if (reply.error) {
        reject(new Error(reply.error.message));
      } else {
        resolve(reply.result);
      }
    });
  });
}

async function outline(): Promise<string> {
  const document = await callHost("red/DocumentSnapshot");
  const headings = document.text.match(/^#{1,6} .+$/gm) ?? [];
  const name = document.path?.split(/[\\/]/).pop() ?? "This buffer";
  return `${name} has ${headings.length} headings`;
}

createInterface({ input: process.stdin }).on("line", async (line) => {
  const message = JSON.parse(line);
  // A response from Red.
  if (message.method === undefined) {
    waiting.get(message.id)?.(message);
    return;
  }
  const { id, method, params } = message;
  if (method === "initialize") {
    const commands = [
      { name: "Outline", metadata: { title: "Show the Markdown outline" } },
    ];
    send({ id, result: { protocol_version: 1, commands, events: [] } });
  } else if (method === "command/execute" && params.name === "Outline") {
    try {
      send({ id, result: { message: await outline() } });
    } catch (error) {
      send({ id, error: { code: -32000, message: (error as Error).message } });
    }
  } else if (method === "shutdown") {
    send({ id, result: null });
  } else if (method === "exit") {
    process.exit(0);
  }
});
```

**Python**

```text
markdown-outline/
├── red-plugin.toml
└── plugin.py
```

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "python3"
args = ["plugin.py"]

[rpc.commands]
aarch64-pc-windows-msvc = "python"
x86_64-pc-windows-msvc = "python"

[activation]
commands = ["Outline"]
```

`markdown-outline/plugin.py`:

```python
#!/usr/bin/env python3
import json
import os
import re
import sys

next_request = 1


def send(message):
    print(json.dumps({"jsonrpc": "2.0", **message}), flush=True)


def call_host(method, params=None):
    """Send a request to Red and wait for its response."""
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    reply = json.loads(sys.stdin.readline())
    if "error" in reply:
        raise RuntimeError(reply["error"]["message"])
    return reply["result"]


def outline():
    document = call_host("red/DocumentSnapshot")
    headings = re.findall(r"^#{1,6} .+$", document["text"], re.MULTILINE)
    path = document["path"]
    name = os.path.basename(path) if path else "This buffer"
    return f"{name} has {len(headings)} headings"


COMMANDS = [
    {"name": "Outline", "metadata": {"title": "Show the Markdown outline"}},
]

while line := sys.stdin.readline():
    message = json.loads(line)
    request_id, method = message.get("id"), message.get("method")
    if method == "initialize":
        result = {"protocol_version": 1, "commands": COMMANDS, "events": []}
        send({"id": request_id, "result": result})
    elif method == "command/execute":
        try:
            send({"id": request_id, "result": {"message": outline()}})
        except RuntimeError as error:
            failure = {"code": -32000, "message": str(error)}
            send({"id": request_id, "error": failure})
    elif method == "shutdown":
        send({"id": request_id, "result": None})
    elif method == "exit":
        sys.exit(0)
```

Red sends `initialize` first. The plugin answers with protocol version 1 and the commands it declared under `[activation]`. Each `:Outline` arrives as a `command/execute` request. To read the document, the plugin sends its own request, `red/DocumentSnapshot`, with a fresh `id`, and Red answers with that same `id`. On `shutdown` the plugin replies, and on `exit` it stops.

Install the package from its parent directory and check that Red lists it:

```shell
red plugin install --path ./markdown-outline
red plugin list
```

Open a Markdown file such as a README and run `:Outline`. Red shows a message like `README.md has 7 headings`. If the file has a fenced shell example, that count is probably one too high: a `# comment` inside a code block looks like a heading to a simple pattern. The next step fixes that.

Red reloads a path install when `red-plugin.toml` changes. After editing the program, save the manifest again (or `touch` it) to pick up the change without restarting Red.

<a id="2-find-the-headings"></a>

## 2. Find the headings

Markdown headings start with one to six `#` characters and a space, except inside fenced code blocks. The parser tracks fences (three or more backticks or tildes, closed by the same character at least as many times), removes optional closing `#` characters, and records each heading's line number. Step 4 uses those line numbers to place the table of contents.

**TypeScript**

```typescript
function parseHeadings(text: string): Heading[] {
  const headings: Heading[] = [];
  let fence: string | null = null;
  text.split("\n").forEach((raw, line) => {
    const row = raw.replace(/\r$/, "");
    const marker = /^ {0,3}(`{3,}|~{3,})/.exec(row)?.[1];
    if (marker) {
      if (!fence) {
        fence = marker;
      } else if (marker[0] === fence[0] && marker.length >= fence.length) {
        fence = null;
      }
      return;
    }
    const heading =
      !fence && /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(row);
    if (heading) {
      headings.push({ level: heading[1].length, title: heading[2], line });
    }
  });
  return headings;
}

function nestedList(
  headings: Heading[],
  item: (heading: Heading, index: number) => string,
): string {
```

**Python**

```python
FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")

HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")

def parse_headings(text):
    headings, fence = [], None
    for line, raw in enumerate(text.split("\n")):
        row = raw.rstrip("\r")
        marker = FENCE.match(row)
        if marker:
            marker = marker.group(1)
            if not fence:
                fence = marker
            elif marker[0] == fence[0] and len(marker) >= len(fence):
                fence = None
            continue
        heading = None if fence else HEADING.match(row)
        if heading:
            headings.append(
                {
                    "level": len(heading.group(1)),
                    "title": heading.group(2),
                    "line": line,
                }
            )
    return headings

def nested_list(headings, item):
    top = min(heading["level"] for heading in headings)
    return "\n".join(
        f"{'  ' * (heading['level'] - top)}- {item(heading)}"
        for heading in headings
    )
```

Splitting on `\n` gives the same zero-based line numbers that Red uses for edits. `nestedList` renders headings as a Markdown list, indented relative to the shallowest heading, which the panel in the next step displays.

<a id="3-show-a-live-outline-in-a-panel"></a>

## 3. Show a live outline in a panel

Now `:Outline` opens a panel. `red/TextPanelCreate` creates it with an ID that only your connection can use, and `red/TextPanelSetContent` replaces its blocks; a block with `"format": "markdown"` renders the nested list. `red/StatuslineSet` sets the plugin's single statusline item. When the plugin stops or reloads, Red removes its panels and statusline item for you.

To keep the outline current, the plugin watches the document. Ask for the snapshot with `"include_document_id": true`, then call `red/DocumentWatch` with that ID. Red collects changes for 50 ms and sends one `document/event` notification with `"event": "changed"`. The event only says that something changed, so the plugin reads the document again. Install the watch before reading, so an edit made in between still produces an event. A plugin can watch two documents at a time; this one follows a single file and releases the previous watch when you switch.

**TypeScript**

```typescript
async function snapshot(selector: Params = {}): Promise<Snapshot> {
  return callHost<Snapshot>("red/DocumentSnapshot", {
    ...selector,
    include_document_id: true,
  });
}

async function openOutline(selector: Params = {}) {
  const document = await snapshot(selector);
  if (!isMarkdown(document.path)) {
    throw new Error("Outline works on Markdown files (.md or .markdown)");
  }
  if (following !== document.document_id) {
    if (following) {
      await callHost("red/DocumentUnwatch", { document_id: following });
    }
    // Watch before reading, so changes made meanwhile still send an event.
    await callHost("red/DocumentWatch", { document_id: document.document_id });
    following = document.document_id;
  }
  await callHost("red/TextPanelCreate", {
    id: PANEL,
    side: "right",
    width: 40,
    title: "Outline",
    actions: panelActions(),
  });
  await refresh();
}

async function refresh() {
  if (!following) return;
  const document = await snapshot({ document_id: following });
  const headings = parseHeadings(document.text).filter(
    (heading) => heading.level <= depth,
  );
  const words =
    document.text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)?.length ?? 0;
  const text = headings.length
    ? nestedList(headings, (heading) => heading.title)
    : "_No headings yet._";
  await callHost("red/TextPanelSetContent", {
    id: PANEL,
    blocks: [{ id: "outline", format: "markdown", text }],
  });
  const count = words.toLocaleString("en-US");
  await callHost("red/StatuslineSet", {
    text: `§ ${headings.length} headings · ${count} words`,
  });
}

async function closeOutline() {
  if (following) {
    await callHost("red/DocumentUnwatch", { document_id: following });
  }
  following = null;
  // The user may have closed the panel already.
  await callHost("red/TextPanelDispose", { id: PANEL }).catch(() => {});
  await callHost("red/StatuslineClear");
}
```

Then route the notification in `dispatch()`:

```typescript
case "document/event":
  if (params.document_id !== following) break;
  enqueue(params.event === "closed" ? closeOutline : refresh);
  break;
```

**Python**

```python
def snapshot(selector=None):
    return call_host(
        "red/DocumentSnapshot",
        {**(selector or {}), "include_document_id": True},
    )

def open_outline(selector=None):
    global following
    document = snapshot(selector)
    if not is_markdown(document["path"]):
        raise ValueError("Outline works on Markdown files (.md or .markdown)")
    document_id = document["document_id"]
    if following != document_id:
        if following:
            call_host("red/DocumentUnwatch", {"document_id": following})
        # Watch before reading, so changes made meanwhile still send an event.
        call_host("red/DocumentWatch", {"document_id": document_id})
        following = document_id
    call_host(
        "red/TextPanelCreate",
        {
            "id": PANEL,
            "side": "right",
            "width": 40,
            "title": "Outline",
            "actions": panel_actions(),
        },
    )
    refresh()

def refresh():
    if not following:
        return
    document = snapshot({"document_id": following})
    headings = [
        h for h in parse_headings(document["text"]) if h["level"] <= depth
    ]
    words = len(re.findall(r"[^\W_][\w'’-]*", document["text"]))
    if headings:
        text = nested_list(headings, lambda heading: heading["title"])
    else:
        text = "_No headings yet._"
    blocks = [{"id": "outline", "format": "markdown", "text": text}]
    call_host("red/TextPanelSetContent", {"id": PANEL, "blocks": blocks})
    status = f"§ {len(headings)} headings · {words:,} words"
    call_host("red/StatuslineSet", {"text": status})

def close_outline():
    global following
    if following:
        call_host("red/DocumentUnwatch", {"document_id": following})
    following = None
    try:
        call_host("red/TextPanelDispose", {"id": PANEL})
    except HostError:
        pass  # the user closed the panel already
    call_host("red/StatuslineClear")
```

Then route the notification in `handle()`:

```python
elif method == "document/event":
    if params["document_id"] == following:
        if params["event"] == "closed":
            close_outline()
        else:
            refresh()
```

Watching changes how the program reads messages. In step 1, nothing could arrive while the plugin waited for a reply. Now a `document/event` can arrive at any moment, including while a handler is waiting for Red. The TypeScript version delivers each response as soon as it arrives and runs handlers one at a time through a queue. The Python version keeps reading while it waits and handles everything else afterward:

**TypeScript**

```typescript
function callHost<T = null>(method: string, params: Params = {}): Promise<T> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
}

let work = Promise.resolve();

function enqueue(task: () => Promise<void>) {
  work = work.then(task).catch((error) => log(error));
}
```

**Python**

```python
def call_host(method, params=None):
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    # Keep reading until our response arrives; handle anything else afterward.
    while True:
        message = receive()
        if "method" in message:
            deferred.append(message)
        elif message.get("id") == request_id:
            if "error" in message:
                raise HostError(
                    message["error"]["code"], message["error"]["message"]
                )
            return message.get("result")

def respond(request_id, result):
    send({"id": request_id, "result": result})

def fail(request_id, code, message):
    send({"id": request_id, "error": {"code": code, "message": message}})

def main():
    while True:
        message = deferred.popleft() if deferred else receive()
        if "method" not in message:
            continue  # a late response to a call we stopped waiting for
        try:
            handle(message)
        except HostError as error:
            log(error)
```

Run `:Outline` and add a `## heading`. The panel and the statusline update as you type. Closing the file sends `"event": "closed"`, and the plugin closes the panel.

<a id="4-write-the-table-of-contents"></a>

## 4. Write the table of contents

Plugins never change buffer memory directly. `red/DocumentApply` takes the snapshot's `document_id` and `expected_revision` and a list of edits. Each edit has a range in zero-based lines and Unicode-scalar characters and an optional `expected_text`, the exact text it replaces. Red checks every range and preimage before changing anything, then applies the batch as one undo step attributed to the plugin, keeping dirty state, undo history, and language servers in sync.

The first `:OutlineToc` inserts a list between `<!-- toc -->` and `<!-- tocstop -->` markers, just before the first section. Later runs replace what is between the markers and pass the current list as `expected_text`, so Red rejects the edit if the list changed after the snapshot. Anchors follow GitHub's rules: lowercase, punctuation removed, spaces turned into hyphens, and repeated titles numbered.

**TypeScript**

```typescript
function slugs(headings: Heading[]): string[] {
  const seen = new Map<string, number>();
  return headings.map(({ title }) => {
    const base = title
      .toLowerCase()
      .trim()
      .replace(/[^\p{L}\p{N}\s_-]/gu, "")
      .replace(/\s/g, "-");
    const count = seen.get(base) ?? 0;
    seen.set(base, count + 1);
    return count ? `${base}-${count}` : base;
  });
}

function tocEntries(headings: Heading[], depth: number): Heading[] {
  const titled = headings.filter((heading) => heading.level === 1).length === 1;
  return headings.filter(
    (heading) => heading.level <= depth && !(titled && heading.level === 1),
  );
}

function tableOfContents(headings: Heading[], depth: number): string {
  const anchors = new Map(
    headings.map((heading, index) => [heading, slugs(headings)[index]]),
  );
  const entries = tocEntries(headings, depth);
  return entries.length
    ? nestedList(
        entries,
        (heading) => `[${heading.title}](#${anchors.get(heading)})`,
      )
    : "";
}

async function updateToc(selector: Params): Promise<string> {
  const document = await snapshot(selector);
  const headings = parseHeadings(document.text);
  const toc = tableOfContents(headings, depth);
  if (!toc) throw new Error("Add a few ## headings first");
  const first = tocEntries(headings, depth)[0].line;
  const lines = document.text.split(/(?<=\n)/); // keep each line's ending
  const start = lines.findIndex((line) => line.trim() === "<!-- toc -->");
  const stop = lines.findIndex(
    (line, index) => index > start && line.trim() === "<!-- tocstop -->",
  );
  let edit;
  if (start >= 0 && stop > start) {
    // Replace the list between the markers. Red rejects the edit if that
    // text changed after the snapshot.
    edit = {
      range: {
        start: { line: start + 1, character: 0 },
        end: { line: stop, character: 0 },
      },
      text: `${toc}\n`,
      expected_text: lines.slice(start + 1, stop).join(""),
    };
  } else {
    // The first time, insert the markers just before the first section.
    const at = { line: first, character: 0 };
    edit = {
      range: { start: at, end: at },
      text: `<!-- toc -->\n${toc}\n<!-- tocstop -->\n\n`,
      expected_text: "",
    };
  }
  const applied = await callHost<{ transaction_id: string; revision: string }>(
    "red/DocumentApply",
    {
      document_id: document.document_id,
      expected_revision: document.revision,
      // The panel may have focus instead of the document.
      allow_inactive: true,
      label: "Update table of contents",
      edits: [edit],
    },
  );
  lastToc = {
    documentId: document.document_id,
    transactionId: applied.transaction_id,
  };
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents updated";
}

async function undoToc(): Promise<string> {
  if (!lastToc) throw new Error("There is no table of contents change to undo");
  // Red only undoes it while it is still the document's latest change.
  await callHost("red/DocumentUndo", {
    document_id: lastToc.documentId,
    transaction_id: lastToc.transactionId,
    allow_inactive: true,
  });
  lastToc = null;
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents change undone";
}
```

**Python**

```python
def slugs(headings):
    seen, result = {}, []
    for heading in headings:
        base = re.sub(r"[^\w\s-]", "", heading["title"].lower().strip())
        base = re.sub(r"\s", "-", base)
        count = seen.get(base, 0)
        seen[base] = count + 1
        result.append(f"{base}-{count}" if count else base)
    return result

def toc_entries(headings, depth):
    # The table of contents leaves out a lone H1, which is the document's title.
    titled = sum(1 for heading in headings if heading["level"] == 1) == 1
    return [
        h
        for h in headings
        if h["level"] <= depth and not (titled and h["level"] == 1)
    ]

def table_of_contents(headings, depth):
    anchors = {
        heading["line"]: slug
        for heading, slug in zip(headings, slugs(headings))
    }
    entries = toc_entries(headings, depth)
    if not entries:
        return ""
    return nested_list(
        entries,
        lambda heading: f"[{heading['title']}](#{anchors[heading['line']]})",
    )

def line_index(lines, marker, after=-1):
    """Index of the first line after `after` that is exactly `marker`, or -1."""
    matches = (
        index
        for index, line in enumerate(lines)
        if index > after and line.strip() == marker
    )
    return next(matches, -1)

def update_toc(selector):
    global last_toc
    document = snapshot(selector)
    headings = parse_headings(document["text"])
    toc = table_of_contents(headings, depth)
    if not toc:
        raise ValueError("Add a few ## headings first")
    first = toc_entries(headings, depth)[0]["line"]
    # Keep each line's ending.
    lines = [line + "\n" for line in document["text"].split("\n")]
    start = line_index(lines, "<!-- toc -->")
    stop = line_index(lines, "<!-- tocstop -->", after=start)
    if start >= 0 and stop > start:
        # Replace the list between the markers. Red rejects the edit if that
        # text changed after the snapshot.
        edit = {
            "range": {
                "start": {"line": start + 1, "character": 0},
                "end": {"line": stop, "character": 0},
            },
            "text": f"{toc}\n",
            "expected_text": "".join(lines[start + 1 : stop]),
        }
    else:
        # The first time, insert the markers just before the first section.
        at = {"line": first, "character": 0}
        edit = {
            "range": {"start": at, "end": at},
            "text": f"<!-- toc -->\n{toc}\n<!-- tocstop -->\n\n",
            "expected_text": "",
        }
    applied = call_host(
        "red/DocumentApply",
        {
            "document_id": document["document_id"],
            "expected_revision": document["revision"],
            # The panel may have focus instead of the document.
            "allow_inactive": True,
            "label": "Update table of contents",
            "edits": [edit],
        },
    )
    last_toc = {
        "document_id": document["document_id"],
        "transaction_id": applied["transaction_id"],
    }
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents updated"

def undo_toc():
    global last_toc
    if not last_toc:
        raise ValueError("There is no table of contents change to undo")
    # Red only undoes it while it is still the document's latest change.
    call_host("red/DocumentUndo", {**last_toc, "allow_inactive": True})
    last_toc = None
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents change undone"
```

Run `:OutlineToc` in a README:

```text
<!-- toc -->
- [Install](#install)
- [Usage](#usage)
  - [Configure](#configure)
- [License](#license)
<!-- tocstop -->
```

The change is an ordinary undo step, so `u` in the document reverts it. `red/DocumentUndo` lets the plugin revert its own transaction, and Red allows that only while it is still the document's latest change. Both calls pass `"allow_inactive": true` so they still reach the document when the panel has focus. If Red answers `-32002`, the document changed after the snapshot; read it again and retry. `-32003` means a range or `expected_text` did not match.

<a id="5-add-panel-buttons"></a>

## 5. Add panel buttons

The `actions` passed to `red/TextPanelCreate` become header buttons, panel shortcuts, and `F1` entries. When someone uses one, Red sends a `panel/event` notification with the action ID, and it sends `"action": "close"` for the panel's own close gesture too. `red/TextPanelSetActions` updates the buttons without resetting the panel's content or scroll position; the plugin uses it to enable Undo after a table of contents change. Red's panels already use Vim-style keys, such as `y` to copy the selected block and `f`, `F`, `t`, and `T` to move to a character, so choose others for your actions.

Copy uses `red/ClipboardWrite`, which can only write and is meant for an explicit action like a button. `red/MessageShow` confirms the result with a short message.

**TypeScript**

```typescript
function panelActions(): Params[] {
  return [
    {
      id: "toc",
      label: "TOC",
      key: "r",
      description: "Write or refresh the table of contents",
    },
    {
      id: "copy",
      label: "Copy",
      key: "c",
      description: "Copy the table of contents",
    },
    {
      id: "undo",
      label: "Undo",
      key: "u",
      description: "Undo the last table of contents change",
      enabled: lastToc !== null,
    },
    { id: "close", label: "Close", compact_label: "x", key: "q" },
  ];
}

async function copyToc() {
  const document = await snapshot({ document_id: following });
  await callHost("red/ClipboardWrite", {
    text: `${tableOfContents(parseHeadings(document.text), depth)}\n`,
  });
  await callHost("red/MessageShow", {
    message: "Table of contents copied",
    severity: "success",
  });
}

async function onPanelAction(action: string) {
  try {
    if (action === "toc") {
      const message = await updateToc({ document_id: following });
      await callHost("red/MessageShow", { message });
    } else if (action === "undo") {
      await callHost("red/MessageShow", { message: await undoToc() });
    } else if (action === "copy") {
      await copyToc();
    } else if (action === "close") {
      await closeOutline();
    }
  } catch (error) {
    await callHost("red/MessageShow", {
      message: (error as Error).message,
      severity: "warning",
    });
  }
}
```

Then route the notification in `dispatch()`:

```typescript
case "panel/event":
  if (params.id === PANEL) enqueue(() => onPanelAction(params.action));
  break;
```

**Python**

```python
def panel_actions():
    return [
        {
            "id": "toc",
            "label": "TOC",
            "key": "r",
            "description": "Write or refresh the table of contents",
        },
        {
            "id": "copy",
            "label": "Copy",
            "key": "c",
            "description": "Copy the table of contents",
        },
        {
            "id": "undo",
            "label": "Undo",
            "key": "u",
            "description": "Undo the last table of contents change",
            "enabled": last_toc is not None,
        },
        {"id": "close", "label": "Close", "compact_label": "x", "key": "q"},
    ]

def copy_toc():
    document = snapshot({"document_id": following})
    toc = table_of_contents(parse_headings(document["text"]), depth)
    call_host("red/ClipboardWrite", {"text": toc + "\n"})
    copied = {"message": "Table of contents copied", "severity": "success"}
    call_host("red/MessageShow", copied)

def on_panel_action(action):
    try:
        if action == "toc":
            message = update_toc({"document_id": following})
            call_host("red/MessageShow", {"message": message})
        elif action == "undo":
            call_host("red/MessageShow", {"message": undo_toc()})
        elif action == "copy":
            copy_toc()
        elif action == "close":
            close_outline()
    except (HostError, ValueError) as error:
        warning = {"message": str(error), "severity": "warning"}
        call_host("red/MessageShow", warning)
```

Then route the notification in `handle()`:

```python
elif method == "panel/event":
    if params["id"] == PANEL:
        on_panel_action(params["action"])
```

Move focus to the panel with `Ctrl-w l`, then press `r` to write or refresh the table of contents, `c` to copy it, `u` to undo the change, or `q` to close the panel.

<a id="6-add-settings-a-picker-and-editor-events"></a>

## 6. Add settings, a picker, and editor events

Users configure plugins in Red's `config.toml`, under `[plugin_config.OWNER]`, where `OWNER` is the name the plugin was installed under (its `[plugin].id` by default). `red/ConfigGet` returns that table, or `null` when there is none:

`~/.config/red/config.toml`:

```toml
[plugin_config.markdown-outline]
max_depth = 3
auto_open = true
```

`:OutlineDepth` opens a picker with `red/PickerOpen`. The request returns right away; the choice arrives later as a `picker/event` notification, either `selected` with the item or `cancelled`. The plugin saves the chosen depth with `red/StorageSet`. Stored values are JSON, survive restarts, and here take precedence over `max_depth`.

With `auto_open`, the outline opens by itself when you open a Markdown file. The plugin declares the `file:opened` event in `[activation].events` and in its `initialize` response, and Red delivers it as an `event/notify` notification. Red starts the plugin on demand for a declared event, so a plugin that listens to `file:opened` starts the first time you open any file. Version 1 also offers `editor:ready` and `file:saved`.

**TypeScript**

```typescript
async function loadSettings() {
  settings = (await callHost<Settings | null>("red/ConfigGet")) ?? {};
  depth =
    (await callHost<number | null>("red/StorageGet", { key: "depth" })) ??
    settings.max_depth ??
    3;
}

async function runCommand(name: string): Promise<string | null> {
  if (name === "Outline") {
    await openOutline();
    return null;
  }
  if (name === "OutlineToc") return updateToc({});
  if (name === "OutlineDepth") {
    const items = [1, 2, 3, 4, 5, 6].map((level) => ({
      id: String(level),
      label: `Up to H${level}`,
      ...(level === depth ? { detail: "current" } : {}),
    }));
    await callHost("red/PickerOpen", {
      id: "depth",
      title: "Outline depth",
      items,
    });
    return null;
  }
  throw new Error(`Unknown command ${name}`);
}

async function onPicked(item: { id: string }) {
  depth = Number(item.id);
  await callHost("red/StorageSet", { key: "depth", value: depth });
  await refresh();
  await callHost("red/MessageShow", {
    message: `Outline shows headings up to H${depth}`,
  });
}
```

Then route the notification in `dispatch()`:

```typescript
case "event/notify": // file:opened, declared in red-plugin.toml
  enqueue(async () => {
    const file = params.payload.file;
    if (settings.auto_open && isMarkdown(file)) {
      await openOutline({ path: file });
    }
  });
  break;
```

**Python**

```python
def load_settings():
    global settings, depth
    settings = call_host("red/ConfigGet") or {}
    stored = call_host("red/StorageGet", {"key": "depth"})
    depth = stored if stored is not None else settings.get("max_depth", 3)

def run_command(name):
    if name == "Outline":
        open_outline()
        return None
    if name == "OutlineToc":
        return update_toc({})
    if name == "OutlineDepth":
        items = [
            {
                "id": str(level),
                "label": f"Up to H{level}",
                **({"detail": "current"} if level == depth else {}),
            }
            for level in range(1, 7)
        ]
        call_host(
            "red/PickerOpen",
            {"id": "depth", "title": "Outline depth", "items": items},
        )
        return None
    raise ValueError(f"Unknown command {name}")

def on_picked(item):
    global depth
    depth = int(item["id"])
    call_host("red/StorageSet", {"key": "depth", "value": depth})
    refresh()
    message = f"Outline shows headings up to H{depth}"
    call_host("red/MessageShow", {"message": message})
```

Then route the notification in `handle()`:

```python
elif method == "event/notify":  # file:opened, declared in red-plugin.toml
    file = params["payload"]["file"]
    if settings.get("auto_open") and is_markdown(file):
        open_outline({"path": file})
```

Here is the complete package. These are the same files the website's test suite runs against a scripted stand-in for Red on every build, and you can also [view the source on GitHub](https://github.com/codersauce/red-website/tree/main/examples/markdown-outline).

**TypeScript**

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "node"
args = ["plugin.ts"]

[activation]
commands = ["Outline", "OutlineToc", "OutlineDepth"]
events = ["file:opened"]
```

`markdown-outline/plugin.ts`:

```typescript
#!/usr/bin/env node
// Markdown Outline: a live outline panel and table of contents for Red.
// Runs with Node.js 22.18 or later, which strips the types when loading it.
import { createInterface } from "node:readline";

// --- Protocol types used by this plugin ---

type Params = Record<string, unknown>;
type Message = {
  jsonrpc: "2.0";
  id?: string | number;
  method?: string;
  params?: any;
  result?: any;
  error?: { code: number; message: string };
};
type Snapshot = {
  path: string | null;
  revision: string;
  text: string;
  document_id: string;
};
type Heading = { level: number; title: string; line: number };
type Settings = { max_depth?: number; auto_open?: boolean };

// --- Talking to Red ---
// One JSON message per line. stdout carries only protocol messages, so logs
// go to stderr.

const send = (message: Params) =>
  process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", ...message })}\n`);
const log = (...args: unknown[]) =>
  console.error("[markdown-outline]", ...args);

class HostError extends Error {
  code: number;
  constructor(code: number, message: string) {
    super(message);
    this.code = code;
  }
}

let nextRequest = 1;
const waiting = new Map<
  string,
  { resolve: (value: any) => void; reject: (error: Error) => void }
>();

function callHost<T = null>(method: string, params: Params = {}): Promise<T> {
  const id = `outline-${nextRequest++}`;
  send({ id, method, params });
  return new Promise((resolve, reject) => waiting.set(id, { resolve, reject }));
}

// Handlers run one at a time. Responses are delivered as soon as they arrive,
// so a handler can await a host call without blocking the message loop.
let work = Promise.resolve();
function enqueue(task: () => Promise<void>) {
  work = work.then(task).catch((error) => log(error));
}

// --- Markdown ---

function parseHeadings(text: string): Heading[] {
  const headings: Heading[] = [];
  let fence: string | null = null;
  text.split("\n").forEach((raw, line) => {
    const row = raw.replace(/\r$/, "");
    const marker = /^ {0,3}(`{3,}|~{3,})/.exec(row)?.[1];
    if (marker) {
      if (!fence) {
        fence = marker;
      } else if (marker[0] === fence[0] && marker.length >= fence.length) {
        fence = null;
      }
      return;
    }
    const heading =
      !fence && /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/.exec(row);
    if (heading) {
      headings.push({ level: heading[1].length, title: heading[2], line });
    }
  });
  return headings;
}

function slugs(headings: Heading[]): string[] {
  const seen = new Map<string, number>();
  return headings.map(({ title }) => {
    const base = title
      .toLowerCase()
      .trim()
      .replace(/[^\p{L}\p{N}\s_-]/gu, "")
      .replace(/\s/g, "-");
    const count = seen.get(base) ?? 0;
    seen.set(base, count + 1);
    return count ? `${base}-${count}` : base;
  });
}

function nestedList(
  headings: Heading[],
  item: (heading: Heading, index: number) => string,
): string {
  const top = Math.min(...headings.map((heading) => heading.level));
  return headings
    .map(
      (heading, index) =>
        `${"  ".repeat(heading.level - top)}- ${item(heading, index)}`,
    )
    .join("\n");
}

// The table of contents leaves out a lone H1, which is the document's title.
function tocEntries(headings: Heading[], depth: number): Heading[] {
  const titled = headings.filter((heading) => heading.level === 1).length === 1;
  return headings.filter(
    (heading) => heading.level <= depth && !(titled && heading.level === 1),
  );
}

function tableOfContents(headings: Heading[], depth: number): string {
  const anchors = new Map(
    headings.map((heading, index) => [heading, slugs(headings)[index]]),
  );
  const entries = tocEntries(headings, depth);
  return entries.length
    ? nestedList(
        entries,
        (heading) => `[${heading.title}](#${anchors.get(heading)})`,
      )
    : "";
}

// --- Plugin state ---

const PANEL = "outline";
let following: string | null = null; // document ID the panel follows
let lastToc: { documentId: string; transactionId: string } | null = null;
let settings: Settings = {};
let depth = 3;

const isMarkdown = (path: string | null) =>
  !!path && /\.(md|markdown)$/i.test(path);

function panelActions(): Params[] {
  return [
    {
      id: "toc",
      label: "TOC",
      key: "r",
      description: "Write or refresh the table of contents",
    },
    {
      id: "copy",
      label: "Copy",
      key: "c",
      description: "Copy the table of contents",
    },
    {
      id: "undo",
      label: "Undo",
      key: "u",
      description: "Undo the last table of contents change",
      enabled: lastToc !== null,
    },
    { id: "close", label: "Close", compact_label: "x", key: "q" },
  ];
}

async function loadSettings() {
  settings = (await callHost<Settings | null>("red/ConfigGet")) ?? {};
  depth =
    (await callHost<number | null>("red/StorageGet", { key: "depth" })) ??
    settings.max_depth ??
    3;
}

async function snapshot(selector: Params = {}): Promise<Snapshot> {
  return callHost<Snapshot>("red/DocumentSnapshot", {
    ...selector,
    include_document_id: true,
  });
}

// --- The outline panel ---

async function openOutline(selector: Params = {}) {
  const document = await snapshot(selector);
  if (!isMarkdown(document.path)) {
    throw new Error("Outline works on Markdown files (.md or .markdown)");
  }
  if (following !== document.document_id) {
    if (following) {
      await callHost("red/DocumentUnwatch", { document_id: following });
    }
    // Watch before reading, so changes made meanwhile still send an event.
    await callHost("red/DocumentWatch", { document_id: document.document_id });
    following = document.document_id;
  }
  await callHost("red/TextPanelCreate", {
    id: PANEL,
    side: "right",
    width: 40,
    title: "Outline",
    actions: panelActions(),
  });
  await refresh();
}

async function refresh() {
  if (!following) return;
  const document = await snapshot({ document_id: following });
  const headings = parseHeadings(document.text).filter(
    (heading) => heading.level <= depth,
  );
  const words =
    document.text.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu)?.length ?? 0;
  const text = headings.length
    ? nestedList(headings, (heading) => heading.title)
    : "_No headings yet._";
  await callHost("red/TextPanelSetContent", {
    id: PANEL,
    blocks: [{ id: "outline", format: "markdown", text }],
  });
  const count = words.toLocaleString("en-US");
  await callHost("red/StatuslineSet", {
    text: `§ ${headings.length} headings · ${count} words`,
  });
}

async function closeOutline() {
  if (following) {
    await callHost("red/DocumentUnwatch", { document_id: following });
  }
  following = null;
  // The user may have closed the panel already.
  await callHost("red/TextPanelDispose", { id: PANEL }).catch(() => {});
  await callHost("red/StatuslineClear");
}

// --- The table of contents ---

async function updateToc(selector: Params): Promise<string> {
  const document = await snapshot(selector);
  const headings = parseHeadings(document.text);
  const toc = tableOfContents(headings, depth);
  if (!toc) throw new Error("Add a few ## headings first");
  const first = tocEntries(headings, depth)[0].line;
  const lines = document.text.split(/(?<=\n)/); // keep each line's ending
  const start = lines.findIndex((line) => line.trim() === "<!-- toc -->");
  const stop = lines.findIndex(
    (line, index) => index > start && line.trim() === "<!-- tocstop -->",
  );
  let edit;
  if (start >= 0 && stop > start) {
    // Replace the list between the markers. Red rejects the edit if that
    // text changed after the snapshot.
    edit = {
      range: {
        start: { line: start + 1, character: 0 },
        end: { line: stop, character: 0 },
      },
      text: `${toc}\n`,
      expected_text: lines.slice(start + 1, stop).join(""),
    };
  } else {
    // The first time, insert the markers just before the first section.
    const at = { line: first, character: 0 };
    edit = {
      range: { start: at, end: at },
      text: `<!-- toc -->\n${toc}\n<!-- tocstop -->\n\n`,
      expected_text: "",
    };
  }
  const applied = await callHost<{ transaction_id: string; revision: string }>(
    "red/DocumentApply",
    {
      document_id: document.document_id,
      expected_revision: document.revision,
      // The panel may have focus instead of the document.
      allow_inactive: true,
      label: "Update table of contents",
      edits: [edit],
    },
  );
  lastToc = {
    documentId: document.document_id,
    transactionId: applied.transaction_id,
  };
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents updated";
}

async function undoToc(): Promise<string> {
  if (!lastToc) throw new Error("There is no table of contents change to undo");
  // Red only undoes it while it is still the document's latest change.
  await callHost("red/DocumentUndo", {
    document_id: lastToc.documentId,
    transaction_id: lastToc.transactionId,
    allow_inactive: true,
  });
  lastToc = null;
  if (following) {
    await callHost("red/TextPanelSetActions", {
      id: PANEL,
      actions: panelActions(),
    });
  }
  return "Table of contents change undone";
}

async function copyToc() {
  const document = await snapshot({ document_id: following });
  await callHost("red/ClipboardWrite", {
    text: `${tableOfContents(parseHeadings(document.text), depth)}\n`,
  });
  await callHost("red/MessageShow", {
    message: "Table of contents copied",
    severity: "success",
  });
}

// --- Commands, events, and notifications ---

const command = (name: string, title: string) => ({
  name,
  metadata: { title, category: "Markdown" },
});

const commands = [
  command("Outline", "Show the Markdown outline"),
  command("OutlineToc", "Write or refresh the table of contents"),
  command("OutlineDepth", "Choose the outline depth"),
];

async function runCommand(name: string): Promise<string | null> {
  if (name === "Outline") {
    await openOutline();
    return null;
  }
  if (name === "OutlineToc") return updateToc({});
  if (name === "OutlineDepth") {
    const items = [1, 2, 3, 4, 5, 6].map((level) => ({
      id: String(level),
      label: `Up to H${level}`,
      ...(level === depth ? { detail: "current" } : {}),
    }));
    await callHost("red/PickerOpen", {
      id: "depth",
      title: "Outline depth",
      items,
    });
    return null;
  }
  throw new Error(`Unknown command ${name}`);
}

async function onPanelAction(action: string) {
  try {
    if (action === "toc") {
      const message = await updateToc({ document_id: following });
      await callHost("red/MessageShow", { message });
    } else if (action === "undo") {
      await callHost("red/MessageShow", { message: await undoToc() });
    } else if (action === "copy") {
      await copyToc();
    } else if (action === "close") {
      await closeOutline();
    }
  } catch (error) {
    await callHost("red/MessageShow", {
      message: (error as Error).message,
      severity: "warning",
    });
  }
}

async function onPicked(item: { id: string }) {
  depth = Number(item.id);
  await callHost("red/StorageSet", { key: "depth", value: depth });
  await refresh();
  await callHost("red/MessageShow", {
    message: `Outline shows headings up to H${depth}`,
  });
}

function dispatch(message: Message) {
  if (message.method === undefined) {
    // A response to one of our host calls.
    const call = waiting.get(String(message.id));
    waiting.delete(String(message.id));
    if (message.error) {
      call?.reject(new HostError(message.error.code, message.error.message));
    } else {
      call?.resolve(message.result ?? null);
    }
    return;
  }
  const { id, method, params } = message;
  switch (method) {
    case "initialize":
      send({
        id,
        result: { protocol_version: 1, commands, events: ["file:opened"] },
      });
      break;
    case "initialized":
      enqueue(loadSettings);
      break;
    case "command/execute":
      enqueue(async () => {
        try {
          const text = await runCommand(params.name);
          send({ id, result: text ? { message: text } : null });
        } catch (error) {
          send({
            id,
            error: { code: -32000, message: (error as Error).message },
          });
        }
      });
      break;
    case "event/notify": // file:opened, declared in red-plugin.toml
      enqueue(async () => {
        const file = params.payload.file;
        if (settings.auto_open && isMarkdown(file)) {
          await openOutline({ path: file });
        }
      });
      break;
    case "document/event":
      if (params.document_id !== following) break;
      enqueue(params.event === "closed" ? closeOutline : refresh);
      break;
    case "panel/event":
      if (params.id === PANEL) enqueue(() => onPanelAction(params.action));
      break;
    case "picker/event":
      if (params.event === "selected") enqueue(() => onPicked(params.item));
      break;
    case "shutdown":
      send({ id, result: null });
      break;
    case "exit":
      process.exit(0);
    default:
      if (id !== undefined) {
        const error = { code: -32601, message: `Unknown method ${method}` };
        send({ id, error });
      }
  }
}

createInterface({ input: process.stdin, crlfDelay: Infinity })
  .on("line", (line) => line && dispatch(JSON.parse(line)))
  .on("close", () => process.exit(0));
```

**Python**

`markdown-outline/red-plugin.toml`:

```toml
schema_version = 1

[plugin]
id = "markdown-outline"
name = "Markdown Outline"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
command = "python3"
args = ["plugin.py"]

[rpc.commands]
aarch64-pc-windows-msvc = "python"
x86_64-pc-windows-msvc = "python"

[activation]
commands = ["Outline", "OutlineToc", "OutlineDepth"]
events = ["file:opened"]
```

`markdown-outline/plugin.py`:

```python
#!/usr/bin/env python3
"""Markdown Outline: a live outline panel and table of contents for Red."""
import json
import re
import sys
from collections import deque

# --- Talking to Red ---
# One JSON message per line. stdout carries only protocol messages, so logs
# go to stderr.

sys.stdin.reconfigure(encoding="utf-8")
sys.stdout.reconfigure(encoding="utf-8")


def send(message):
    message = {"jsonrpc": "2.0", **message}
    line = json.dumps(message, ensure_ascii=False, separators=(",", ":"))
    sys.stdout.write(line + "\n")
    sys.stdout.flush()


def respond(request_id, result):
    send({"id": request_id, "result": result})


def fail(request_id, code, message):
    send({"id": request_id, "error": {"code": code, "message": message}})


def log(*args):
    print("[markdown-outline]", *args, file=sys.stderr)


def receive():
    line = sys.stdin.readline()
    if not line:
        sys.exit(0)  # Red closed the connection
    return json.loads(line)


class HostError(Exception):
    def __init__(self, code, message):
        super().__init__(message)
        self.code = code


next_request = 1
deferred = deque()  # messages that arrived while waiting for a host response


def call_host(method, params=None):
    global next_request
    request_id = f"outline-{next_request}"
    next_request += 1
    send({"id": request_id, "method": method, "params": params or {}})
    # Keep reading until our response arrives; handle anything else afterward.
    while True:
        message = receive()
        if "method" in message:
            deferred.append(message)
        elif message.get("id") == request_id:
            if "error" in message:
                raise HostError(
                    message["error"]["code"], message["error"]["message"]
                )
            return message.get("result")


# --- Markdown ---

FENCE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
HEADING = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$")


def parse_headings(text):
    headings, fence = [], None
    for line, raw in enumerate(text.split("\n")):
        row = raw.rstrip("\r")
        marker = FENCE.match(row)
        if marker:
            marker = marker.group(1)
            if not fence:
                fence = marker
            elif marker[0] == fence[0] and len(marker) >= len(fence):
                fence = None
            continue
        heading = None if fence else HEADING.match(row)
        if heading:
            headings.append(
                {
                    "level": len(heading.group(1)),
                    "title": heading.group(2),
                    "line": line,
                }
            )
    return headings


def slugs(headings):
    seen, result = {}, []
    for heading in headings:
        base = re.sub(r"[^\w\s-]", "", heading["title"].lower().strip())
        base = re.sub(r"\s", "-", base)
        count = seen.get(base, 0)
        seen[base] = count + 1
        result.append(f"{base}-{count}" if count else base)
    return result


def nested_list(headings, item):
    top = min(heading["level"] for heading in headings)
    return "\n".join(
        f"{'  ' * (heading['level'] - top)}- {item(heading)}"
        for heading in headings
    )


def toc_entries(headings, depth):
    # The table of contents leaves out a lone H1, which is the document's title.
    titled = sum(1 for heading in headings if heading["level"] == 1) == 1
    return [
        h
        for h in headings
        if h["level"] <= depth and not (titled and h["level"] == 1)
    ]


def table_of_contents(headings, depth):
    anchors = {
        heading["line"]: slug
        for heading, slug in zip(headings, slugs(headings))
    }
    entries = toc_entries(headings, depth)
    if not entries:
        return ""
    return nested_list(
        entries,
        lambda heading: f"[{heading['title']}](#{anchors[heading['line']]})",
    )


# --- Plugin state ---

PANEL = "outline"
following = None  # document ID the panel follows
last_toc = None
settings = {}
depth = 3


MARKDOWN = re.compile(r"\.(md|markdown)$", re.IGNORECASE)


def is_markdown(path):
    return bool(path and MARKDOWN.search(path))


def panel_actions():
    return [
        {
            "id": "toc",
            "label": "TOC",
            "key": "r",
            "description": "Write or refresh the table of contents",
        },
        {
            "id": "copy",
            "label": "Copy",
            "key": "c",
            "description": "Copy the table of contents",
        },
        {
            "id": "undo",
            "label": "Undo",
            "key": "u",
            "description": "Undo the last table of contents change",
            "enabled": last_toc is not None,
        },
        {"id": "close", "label": "Close", "compact_label": "x", "key": "q"},
    ]


def load_settings():
    global settings, depth
    settings = call_host("red/ConfigGet") or {}
    stored = call_host("red/StorageGet", {"key": "depth"})
    depth = stored if stored is not None else settings.get("max_depth", 3)


def snapshot(selector=None):
    return call_host(
        "red/DocumentSnapshot",
        {**(selector or {}), "include_document_id": True},
    )


# --- The outline panel ---


def open_outline(selector=None):
    global following
    document = snapshot(selector)
    if not is_markdown(document["path"]):
        raise ValueError("Outline works on Markdown files (.md or .markdown)")
    document_id = document["document_id"]
    if following != document_id:
        if following:
            call_host("red/DocumentUnwatch", {"document_id": following})
        # Watch before reading, so changes made meanwhile still send an event.
        call_host("red/DocumentWatch", {"document_id": document_id})
        following = document_id
    call_host(
        "red/TextPanelCreate",
        {
            "id": PANEL,
            "side": "right",
            "width": 40,
            "title": "Outline",
            "actions": panel_actions(),
        },
    )
    refresh()


def refresh():
    if not following:
        return
    document = snapshot({"document_id": following})
    headings = [
        h for h in parse_headings(document["text"]) if h["level"] <= depth
    ]
    words = len(re.findall(r"[^\W_][\w'’-]*", document["text"]))
    if headings:
        text = nested_list(headings, lambda heading: heading["title"])
    else:
        text = "_No headings yet._"
    blocks = [{"id": "outline", "format": "markdown", "text": text}]
    call_host("red/TextPanelSetContent", {"id": PANEL, "blocks": blocks})
    status = f"§ {len(headings)} headings · {words:,} words"
    call_host("red/StatuslineSet", {"text": status})


def close_outline():
    global following
    if following:
        call_host("red/DocumentUnwatch", {"document_id": following})
    following = None
    try:
        call_host("red/TextPanelDispose", {"id": PANEL})
    except HostError:
        pass  # the user closed the panel already
    call_host("red/StatuslineClear")


# --- The table of contents ---


def line_index(lines, marker, after=-1):
    """Index of the first line after `after` that is exactly `marker`, or -1."""
    matches = (
        index
        for index, line in enumerate(lines)
        if index > after and line.strip() == marker
    )
    return next(matches, -1)


def update_toc(selector):
    global last_toc
    document = snapshot(selector)
    headings = parse_headings(document["text"])
    toc = table_of_contents(headings, depth)
    if not toc:
        raise ValueError("Add a few ## headings first")
    first = toc_entries(headings, depth)[0]["line"]
    # Keep each line's ending.
    lines = [line + "\n" for line in document["text"].split("\n")]
    start = line_index(lines, "<!-- toc -->")
    stop = line_index(lines, "<!-- tocstop -->", after=start)
    if start >= 0 and stop > start:
        # Replace the list between the markers. Red rejects the edit if that
        # text changed after the snapshot.
        edit = {
            "range": {
                "start": {"line": start + 1, "character": 0},
                "end": {"line": stop, "character": 0},
            },
            "text": f"{toc}\n",
            "expected_text": "".join(lines[start + 1 : stop]),
        }
    else:
        # The first time, insert the markers just before the first section.
        at = {"line": first, "character": 0}
        edit = {
            "range": {"start": at, "end": at},
            "text": f"<!-- toc -->\n{toc}\n<!-- tocstop -->\n\n",
            "expected_text": "",
        }
    applied = call_host(
        "red/DocumentApply",
        {
            "document_id": document["document_id"],
            "expected_revision": document["revision"],
            # The panel may have focus instead of the document.
            "allow_inactive": True,
            "label": "Update table of contents",
            "edits": [edit],
        },
    )
    last_toc = {
        "document_id": document["document_id"],
        "transaction_id": applied["transaction_id"],
    }
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents updated"


def undo_toc():
    global last_toc
    if not last_toc:
        raise ValueError("There is no table of contents change to undo")
    # Red only undoes it while it is still the document's latest change.
    call_host("red/DocumentUndo", {**last_toc, "allow_inactive": True})
    last_toc = None
    if following:
        call_host(
            "red/TextPanelSetActions",
            {"id": PANEL, "actions": panel_actions()},
        )
    return "Table of contents change undone"


def copy_toc():
    document = snapshot({"document_id": following})
    toc = table_of_contents(parse_headings(document["text"]), depth)
    call_host("red/ClipboardWrite", {"text": toc + "\n"})
    copied = {"message": "Table of contents copied", "severity": "success"}
    call_host("red/MessageShow", copied)


# --- Commands, events, and notifications ---


def command(name, title):
    return {"name": name, "metadata": {"title": title, "category": "Markdown"}}


COMMANDS = [
    command("Outline", "Show the Markdown outline"),
    command("OutlineToc", "Write or refresh the table of contents"),
    command("OutlineDepth", "Choose the outline depth"),
]


def run_command(name):
    if name == "Outline":
        open_outline()
        return None
    if name == "OutlineToc":
        return update_toc({})
    if name == "OutlineDepth":
        items = [
            {
                "id": str(level),
                "label": f"Up to H{level}",
                **({"detail": "current"} if level == depth else {}),
            }
            for level in range(1, 7)
        ]
        call_host(
            "red/PickerOpen",
            {"id": "depth", "title": "Outline depth", "items": items},
        )
        return None
    raise ValueError(f"Unknown command {name}")


def on_panel_action(action):
    try:
        if action == "toc":
            message = update_toc({"document_id": following})
            call_host("red/MessageShow", {"message": message})
        elif action == "undo":
            call_host("red/MessageShow", {"message": undo_toc()})
        elif action == "copy":
            copy_toc()
        elif action == "close":
            close_outline()
    except (HostError, ValueError) as error:
        warning = {"message": str(error), "severity": "warning"}
        call_host("red/MessageShow", warning)


def on_picked(item):
    global depth
    depth = int(item["id"])
    call_host("red/StorageSet", {"key": "depth", "value": depth})
    refresh()
    message = f"Outline shows headings up to H{depth}"
    call_host("red/MessageShow", {"message": message})


def handle(message):
    request_id = message.get("id")
    method = message["method"]
    params = message.get("params") or {}
    if method == "initialize":
        events = ["file:opened"]
        result = {
            "protocol_version": 1,
            "commands": COMMANDS,
            "events": events,
        }
        respond(request_id, result)
    elif method == "initialized":
        load_settings()
    elif method == "command/execute":
        try:
            text = run_command(params["name"])
            respond(request_id, {"message": text} if text else None)
        except (HostError, ValueError) as error:
            fail(request_id, -32000, str(error))
    elif method == "event/notify":  # file:opened, declared in red-plugin.toml
        file = params["payload"]["file"]
        if settings.get("auto_open") and is_markdown(file):
            open_outline({"path": file})
    elif method == "document/event":
        if params["document_id"] == following:
            if params["event"] == "closed":
                close_outline()
            else:
                refresh()
    elif method == "panel/event":
        if params["id"] == PANEL:
            on_panel_action(params["action"])
    elif method == "picker/event":
        if params["event"] == "selected":
            on_picked(params["item"])
    elif method == "shutdown":
        respond(request_id, None)
    elif method == "exit":
        sys.exit(0)
    elif request_id is not None:
        fail(request_id, -32601, f"Unknown method {method}")


def main():
    while True:
        message = deferred.popleft() if deferred else receive()
        if "method" not in message:
            continue  # a late response to a call we stopped waiting for
        try:
            handle(message)
        except HostError as error:
            log(error)


main()
```

<a id="7-install-reload-and-distribute"></a>

## 7. Install, reload, and distribute

A path install runs your files in place. While Red is running, saving or touching `red-plugin.toml` reloads the plugin: Red starts and validates the new process first, swaps it in, and disposes the panels, pickers, statusline item, and document watches the old process owned.

```shell
red plugin install --path ./markdown-outline
red plugin install owner/repository
red plugin update markdown-outline
red plugin disable markdown-outline
red plugin enable markdown-outline
red plugin remove markdown-outline
```

### Environment filtering and explicit grants

Red starts RPC child processes with a cleared environment and inherits only baseline OS variables (`HOME`, `PATH`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `LANG`, `LC_ALL`, `TERM`, `SSH_AUTH_SOCK`, `XDG_*`, and Windows system paths). Users who want a plugin to see another variable grant it in their own configuration:

```toml
[plugin_permissions.markdown-outline]
inherit_env = ["MY_SERVICE_TOKEN"]
```

A package manifest cannot grant itself environment variables. RPC plugins run as supervised native child processes: environment filtering prevents accidental secret leakage, not operating-system sandboxing.

### Compiled plugins and release artifacts

For plugins that should not need a language runtime on the user's machine, omit `command` and declare per-target GitHub release binaries with SHA-256 digests under `[rpc.artifacts.TARGET]`:

```toml
[plugin]
id = "document-tools"
name = "Document Tools"
version = "0.1.0"
red_api = "^0.19.0"

[rpc]
args = []

[rpc.artifacts.aarch64-apple-darwin]
url = "https://github.com/example/document-tools/releases/download/v0.1.0/document-tools-aarch64-apple-darwin"
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
```

Red selects `[rpc.commands.TARGET]` first, then `rpc.command`, then `[rpc.artifacts.TARGET]`. GitHub installs and updates verify the SHA-256 digest and 128 MiB ceiling before swapping the package; local `--path` installs use local commands without downloading release artifacts.

<a id="use-another-language"></a>

## Use another language

The protocol doesn't depend on TypeScript or Python. Any program that reads and writes JSON lines can be a Red plugin, as long as it:

- reads `stdin` line by line and writes one compact JSON object per line to `stdout`, flushing after each, with logs on `stderr`;
- answers `initialize` with `"protocol_version": 1` and exactly its declared commands and events, then waits for `initialized` before calling `red/*` methods;
- gives every request a unique `id` and keeps reading while it waits, because notifications can arrive before the response;
- answers `shutdown` and exits on `exit`; and
- keeps revisions and IDs as strings and counts characters as Unicode scalars.

Compiled languages such as Rust or Go can ship per-target binaries as release artifacts, as described in step 7. The [protocol reference](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/RPC_PLUGIN_PROTOCOL.md) lists every method, error code, and limit.

<a id="8-troubleshoot-common-failures"></a>

## 8. Troubleshoot common failures

| Symptom                                                                           | Cause and fix                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A command does nothing the first time, then reports `unknown command`             | Red stopped the plugin for this session, which it calls quarantine. Common causes: `initialize` did not answer within 5 seconds, or its commands did not match the manifest. Red writes the reason to `red.log` in its configuration directory (for example `~/.config/red/red.log`). Fix the cause, then save `red-plugin.toml` to reload or restart Red. Node.js version managers whose shims download a runtime on first use can exceed the 5-second limit; run `node --version` once in a terminal first. |
| Plugin fails to start or the executable is not found                              | A bare `command` such as `node` or `python3` resolves on `PATH`; a command containing `/` or `\` resolves relative to the package root, without a shell or `~` expansion. On Windows, add `[rpc.commands]` entries such as `x86_64-pc-windows-msvc = "python"`.                                                                                                                                                                                                                                               |
| Node.js reports an unknown `.ts` extension or a syntax error at a type annotation | Running `.ts` files directly needs Node.js 22.18 or later. Upgrade Node.js, or on 22.6 through 22.17 set `args = ["--experimental-strip-types", "plugin.ts"]`.                                                                                                                                                                                                                                                                                                                                                |
| Plugin disconnects right after printing debug text                                | Standard output is reserved for JSON-RPC messages. Any `console.log(...)` or `print(...)` on `stdout` breaks the framing. Log to `stderr` with `console.error(...)` or `print(..., file=sys.stderr)`.                                                                                                                                                                                                                                                                                                         |
| A panel shortcut doesn't reach the plugin                                         | Red's panels reserve Vim-style keys for themselves, such as `y` to copy the selected block and `f`, `F`, `t`, and `T` to move to a character. Choose a different `key` for the action.                                                                                                                                                                                                                                                                                                                        |
| Host method returns `-32601` (method not found) or `-32602` (invalid params)      | The protocol exposes the typed version 1 `red/*` methods listed in `initialize.params.host_methods`, not Red's internal commands. Unknown parameter keys are rejected; check names against the protocol reference.                                                                                                                                                                                                                                                                                            |
| `red/DocumentApply` fails with `-32002` or `-32003`                               | `-32002` means the document changed after `red/DocumentSnapshot`; take a fresh snapshot before retrying. `-32003` means a range or `expected_text` did not match. In JavaScript, count characters with `Array.from(text).length`, not `text.length`.                                                                                                                                                                                                                                                          |
| Command times out after 30 seconds                                                | The handler called a `red/*` method before `initialized`, or it waited for a response without reading `stdin`. Keep reading while you wait, as in step 3.                                                                                                                                                                                                                                                                                                                                                     |

[Open the complete JSON-RPC protocol specification](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/RPC_PLUGIN_PROTOCOL.md)

# Write a Husk plugin

Canonical HTML: [Write a Husk plugin](https://getred.dev/docs/husk). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Use Red's embedded Husk language to register commands, keep private state, and request editor-owned operations.

<a id="create-the-smallest-plugin"></a>

## Create the smallest plugin

Husk is Red's embedded scripting language. Place `hello.hk` in the `plugins` directory under Red's configuration directory, then register the filename in `config.toml`:

```toml
[plugins]
hello = "hello.hk"
```

```husk
#[red::command(
    name = "HelloWorld",
    title = "Say hello",
    category = "Example",
)]
fn hello_world() {
    red::execute("Print", "Hello from Husk");
}
```

The command is available as `:HelloWorld` and through the command palette.

<a id="keep-plugin-state"></a>

## Keep plugin state

```husk
struct PluginState {
    count: i64,
}

#[red::state]
fn initial_state() -> PluginState {
    return PluginState { count: 0 };
}
```

Read private state with `red::state()` and update named fields with `red::state_patch(...)`. Use declaration attributes for commands, fixed events, configuration, and lifecycle hooks; keep `red::on` for event names known only at runtime.

<a id="use-the-host-boundary"></a>

## Use the host boundary

| Function           | Purpose                                     |
| ------------------ | ------------------------------------------- |
| `red::execute`     | Request a fire-and-forget editor action     |
| `red::request`     | Request data and receive one typed callback |
| `red::on`          | Subscribe to an editor event                |
| `red::state`       | Read the plugin's private typed record      |
| `red::state_patch` | Update selected state fields                |
| `red::log`         | Write diagnostic information to Red's log   |

Red remains the owner of buffers, undo history, focus, confirmations, sessions, and filesystem reconciliation. Plugins request attributed operations through the versioned host API rather than changing editor state directly.

[Open the complete host API reference](https://github.com/codersauce/red/blob/eb5e2b6be15325abd12adbfac816f7844afff42c/docs/PLUGIN_API.md)

<a id="use-the-standalone-husk-cli"></a>

## Use the standalone Husk CLI

The Husk CLI is built separately from the Red editor binary:

```shell
cargo install --path crates/husk-cli
husk check script.hk
husk run script.hk
husk test path/to/package
husk repl
```

Standalone Husk grants no ambient filesystem, network, environment, process, clock, or random access. Embedders add capabilities explicitly.

# Navigate and edit

Canonical HTML: [Navigate and edit](https://getred.dev/docs/editor/navigation). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Move through a project with Red's file tools and use its Vim-style editing model without guessing which commands are available.

<a id="open-files-and-projects"></a>

## Open files and projects

```shell
red src/main.rs
red src/main.rs src/lib.rs
red -r path/to/project src/main.rs
```

The `-r` option sets the workspace root explicitly. Inside Red, press `Ctrl-p` for the file picker, `Ctrl-j` or `Space b` for open buffers, and `Ctrl-e` for the file tree.

Add a line number to a file-picker query to open that location. For example, `render:240` opens the selected matching file at line 240.

Press `Alt-p` or `F4` in the file, buffer, or project-search picker to hide or restore its preview. Your query and selection stay in place while long paths get more room.

<a id="move-in-normal-mode"></a>

## Move in Normal mode

`h j k l`Move left, down, up, and right

`w b e`Move by word

`0 ^ $`Move to line boundaries

`gg / G`Move to the first or last line

`Ctrl-u / Ctrl-d`Move half a page

`Ctrl-o / Ctrl-i`Move backward or forward through jumps

Use `gj` and `gk` to move by displayed rows when long lines wrap.

<a id="edit-with-motions"></a>

## Edit with motions

Operators combine with motions and text objects. `dw` deletes a word, `ci(` changes inside parentheses, and `y$` copies to the end of the line. Counts work with supported actions: `3dw` deletes three words.

| Keys           | Result                                      |
| -------------- | ------------------------------------------- |
| `i`, `a`, `o`  | Enter Insert mode at common positions       |
| `x`, `dd`, `D` | Delete a character, line, or to line end    |
| `u`, `Ctrl-r`  | Undo and redo                               |
| `p`, `P`       | Paste after or before the cursor            |
| `.`            | Repeat the last completed change            |
| `:w`, `:wa`    | Save the current file or all modified files |

<a id="use-syntax-aware-movement"></a>

## Use syntax-aware movement

When the current language supplies structural queries, `]f` and `[f` move between functions, `]c` and `[c` move between classes, and `]m` and `[m` move between calls. Use `af`, `if`, `ac`, `ic`, `am`, or `im` as Visual selections or operator targets.

<a id="edit-repeated-text-with-multiple-cursors"></a>

## Edit repeated text with multiple cursors

Press `Ctrl-n` on a word to select it, then press it again to add the next occurrence. Use `q` to skip one match, `Q` to remove the active selection, and `n` or `N` to move through matches. Press `c`, type the replacement, and press `Esc`; one `u` undoes the complete multi-cursor edit.

`Ctrl-Up` and `Ctrl-Down` add vertical cursors while preserving display columns across tabs and spaces.

# Search and replace

Canonical HTML: [Search and replace](https://getred.dev/docs/editor/search). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Search one buffer, the whole project, or the file tree, and make controlled replacements with Red's regular-expression syntax.

<a id="search-the-current-buffer"></a>

## Search the current buffer

`/`Search forward with a live preview

`?`Search backward

`n / N`Repeat in the same or opposite direction

`*`Search for the word under the cursor

`:noh`Clear search highlighting

Search patterns use Rust regular-expression syntax. Search history is shared between `/` and `?` and persists across restarts.

<a id="search-the-project"></a>

## Search the project

Press `Space g` to open project search. Type a query, move through matches with the arrow keys or `Ctrl-n`/`Ctrl-p`, and press `Enter` to open a result. Project search runs `rg`, so install ripgrep if Red reports that the process is unavailable.

<a id="search-the-file-tree"></a>

## Search the file tree

Press `Ctrl-e` to open the file tree, then `/` to search paths recursively. `Enter` opens a file or reveals a directory, `Ctrl-Enter` reveals without opening, and `Esc` restores the tree. Press `D` to search only directories, `f` to keep a filter, and `Ctrl-x` to clear it.

<a id="replace-text"></a>

## Replace text

```red-command
:%s/old/new/g
:%s/(old)-([0-9]+)/$1-$2/g
:'<,'>s/old/new/gc
```

Red supports the current line, the whole buffer with `%`, numeric line ranges, and the last Visual selection. Add `g` for every match on a line, `i` for case-insensitive matching, or `c` to confirm each replacement.

> **Replacement syntax**
>
> Captures use Rust forms such as `$1` and `$name`. Use `\r` to split a line. Vim magic modes and expression replacements aren't supported.

# Buffers and windows

Canonical HTML: [Buffers and windows](https://getred.dev/docs/editor/windows). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Arrange files in splits, move between editor and plugin panes, and resolve files that changed outside Red.

<a id="move-between-buffers"></a>

## Move between buffers

`Ctrl-j`Open the buffer picker

`Space Space`Switch to the previous buffer

`Space n / Space p`Move through the buffer list

`:bn / :bp`Select the next or previous buffer

`:bd`Delete the current buffer

`:ls`List open buffers

<a id="browse-directories-in-a-buffer"></a>

## Browse directories in a buffer

Run `red .` or open a directory with `:e path/to/dir`. Move and search with normal editor keys, press `Enter` to open an entry, and press `-` to go to the parent directory. Use `R` to refresh the listing. Directory buffers are read-only and stay in the buffer list so you can return to them.

<a id="arrange-windows-and-panes"></a>

## Arrange windows and panes

| Keys             | Action                                           |
| ---------------- | ------------------------------------------------ |
| `Ctrl-w s`       | Split horizontally                               |
| `Ctrl-w v`       | Split vertically                                 |
| `Ctrl-w h/j/k/l` | Move between editor windows and docked panes     |
| `Ctrl-w H/J/K/L` | Move the focused window or pane to an outer edge |
| `Ctrl-w >/<`     | Resize horizontally                              |
| `Ctrl-w +/-`     | Resize vertically                                |
| `Ctrl-w =`       | Balance splits or restore a pane's original size |
| `Ctrl-w z`       | Zoom the focused editor window or pane           |
| `Ctrl-w c`       | Close the focused window                         |

You can also drag editor and pane dividers with the mouse. Press `Esc` during a drag to cancel it.

<a id="handle-a-file-changed-on-disk"></a>

## Handle a file changed on disk

Red reloads a clean buffer when another program changes its file. If the buffer has unsaved edits, Red keeps your text and marks the conflict instead of overwriting either version.

`:diffdisk`Compare the buffer with the current disk version

`:e!`Discard local edits and reload from disk

`:w file`Save the buffer under another name

`:w!`Deliberately overwrite the changed disk file

Ordinary `:w` and `:wa` don't silently overwrite an external change.

# Work with Git

Canonical HTML: [Work with Git](https://getred.dev/docs/git). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Review changes, stage hunks or lines, write commits, and use repository operations without leaving Red.

<a id="open-the-git-workspace"></a>

## Open the Git workspace

Press `Space G`. The file list groups staged, unstaged, untracked, and conflicted paths. The detail pane follows the selected file and shows its diff.

Use `Tab` or `Ctrl-w w` to switch panes. Press `?` or `F1` for the actions available in the focused pane.

<a id="review-and-stage-changes"></a>

## Review and stage changes

| Keys          | Action                                                |
| ------------- | ----------------------------------------------------- |
| `[h`, `]h`    | Move between hunks                                    |
| `v`           | Select changed lines                                  |
| `s`, `u`, `x` | Stage, unstage, or discard the selected line or range |
| `S`, `U`, `X` | Stage, unstage, or discard the current hunk           |
| `W`           | Toggle diff wrapping                                  |
| `L`           | Open the complete patch in a scratch buffer           |

Discard operations ask for confirmation. Git authentication uses your existing SSH agent or credential helper.

<a id="create-a-commit"></a>

## Create a commit

Press `Space c c` to open the commit editor. Its first lines show the current branch and staged files. Write the message, then use `:w` or `:wq` to submit it. `:q` cancels.

The commit menu can draft a message from the staged diff when Codex is installed and authenticated. Red places the draft in the editor so you can change it before committing.

<a id="use-repository-operations"></a>

## Use repository operations

The workspace also exposes fetch, pull, push, branches, remotes, tags, stashes, worktrees, logs, reset, and interactive rebase. Open the action list with `?` instead of memorizing every binding.

# Agent and inline assist

Canonical HTML: [Agent and inline assist](https://getred.dev/docs/agent). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Set up Codex, use the full Agent workspace or a bounded inline request, and know when a change is saved to disk.

<a id="set-up-codex"></a>

## Set up Codex

Agent features require Codex CLI 0.144.1 or newer. Install Codex, authenticate it, and run Red's offline prerequisite check:

```shell
codex login
red --agent-check --strict
```

Red starts `codex app-server --stdio` when the first Agent session needs it. If startup fails, Red keeps your prompt so you can fix the setup and retry.

<a id="use-the-agent-workspace"></a>

## Use the Agent workspace

1. **Open Agent.**

   Press `Space A` or run `:Agent`.

2. **Write the request.**

   Press `Enter` to send. Use `Alt+Enter`, `Shift+Enter`, or `Ctrl+J` for a newline.

3. **Follow the work.**

   The transcript streams the response and groups tool activity for the turn. Press `Ctrl-c` to interrupt.

4. **Inspect the result.**

   Agent file writes pass through Red's revision-checked editor tools and are saved to disk. Use `:AgentHistory` to inspect attributed transactions.

Press `Alt+m` in the Agent pane or run `:AgentModel` to choose the model and reasoning effort for that conversation. This doesn't change your global Codex settings.

<a id="ask-about-the-current-code"></a>

## Ask about the current code

Press `Space i` for inline assist. In Normal mode, Red targets the enclosing function when syntax information is available and otherwise uses the current line. Visual and Visual Line modes use the exact selection. Visual Block isn't supported.

An exact edit can apply immediately while its original popup remains in the foreground. The result is one **unsaved, undoable** editor transaction. Background results and wider same-file proposals wait for review. In the diff, press `a` to apply or `d` to decline; `Enter` opens the review but doesn't approve it.

> **Full Agent and inline assist write differently**
>
> Full Agent file tools save their editor changes to disk. Inline code edits remain unsaved until you save the buffer. Comments and explanations don't change source text.

<a id="understand-the-boundary"></a>

## Understand the boundary

Agent threads run with a read-only Codex sandbox and no native shell or direct file-edit path. Red supplies bounded tools for workspace files, open-buffer state, diagnostics, navigation, annotations, and revision-checked edits. Reads use the latest Red buffer contents, including unsaved changes.

Paths must remain inside the physical workspace. Red rejects traversal, symlinks, special files, protected paths, stale revisions, and unsafe writes. Secret-like paths are excluded unless you set `allow_sensitive_paths = true` under `[agent]`.

Configured MCP servers and Codex features are disabled unless you name them in `enabled_mcp_servers` or `enabled_codex_features`.

<a id="return-to-earlier-work"></a>

## Return to earlier work

Press `Space H` or run `:InlineHistory` to browse inline prompts, answers, ready edits, and applied changes. Press `Space N` to reopen the latest completed inline result.

Agent conversation state and inline history participate in Red's recovery snapshots. After a restart, Red resumes a persisted Codex thread when possible. If it can't, the old transcript remains visible as archived context and the next prompt begins a new session.

# Detach and recover

Canonical HTML: [Detach and recover](https://getred.dev/docs/sessions). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Keep a live editor across terminal disconnects on macOS and Linux, or restore an atomic snapshot after the editor stops.

<a id="keep-a-live-session-running"></a>

## Keep a live session running

Detach and attach are available on macOS and Linux. Start a named session:

```shell
red --detach=refactor src/main.rs
```

The terminal attaches immediately. Press `Ctrl-\` to leave the TUI while the editor, unsaved buffers, plugins, language servers, running shell jobs, and Codex app-server process stay alive.

```shell
red --attach refactor
red --stop refactor
```

Only one TUI can attach at a time. Sessions are local to the current OS user and use a private Unix socket rather than a TCP port. Windows supports recovery but not detach and attach.

<a id="use-the-default-session-name"></a>

## Use the default session name

```shell
red --detach src/main.rs
```

Without an explicit name, Red uses `default`. Named sessions require the `--detach=NAME` form so a filename can't be mistaken for a session name.

<a id="restore-after-a-crash"></a>

## Restore after a crash

```shell
red --resume
```

Recovery is available on every supported platform. Red restores the newest valid snapshot, including dirty buffers, window layout, cursors, registers, marks, undo history, and Agent conversation state. Restored dirty text stays in memory; Red doesn't write it to disk until you save.

> **Don't resume a running editor**
>
> Interactive owners don't currently hold an exclusive recovery lock. Use `--attach` for a live detached owner and `--resume` only after the owner has stopped.

<a id="know-which-recovery-path-applies"></a>

## Know which recovery path applies

| Situation                  | Use                         | What happens                                                |
| -------------------------- | --------------------------- | ----------------------------------------------------------- |
| Terminal or SSH disconnect | `red --attach NAME`         | Reconnects to the same live editor owner                    |
| Editor or machine stopped  | `red --resume`              | Loads the newest valid atomic snapshot                      |
| Normal clean exit          | `red` in the same directory | Restores clean file-backed buffers, views, and split layout |

# Command-line reference

Canonical HTML: [Command-line reference](https://getred.dev/docs/reference/cli). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Look up Red's editor, validation, runtime asset, Agent, session, plugin, language, and keyboard commands.

<a id="open-the-editor"></a>

## Open the editor

| Command                      | Purpose                                          |
| ---------------------------- | ------------------------------------------------ |
| `red [FILES...]`             | Open zero or more files                          |
| `red .` or `red PATH/TO/DIR` | Browse a directory in a read-only editor buffer  |
| `red -r PATH [FILES...]`     | Set the workspace root before opening files      |
| `red -c TOML [FILES...]`     | Apply a repeatable inline configuration override |
| `red --version`              | Print the installed version                      |
| `red --help`                 | Print the generated command reference            |

<a id="check-the-installation-and-configuration"></a>

## Check the installation and configuration

| Command                      | Purpose                                                                   |
| ---------------------------- | ------------------------------------------------------------------------- |
| `red --self-check`           | Validate the bundled runtime and plugins                                  |
| `red update --check`         | Check for a newer stable release without replacing the executable         |
| `red update`                 | Install the latest stable release for an official standalone installation |
| `red --check-config`         | Validate the effective user configuration                                 |
| `red --agent-check`          | Report Codex prerequisites without installing anything                    |
| `red --agent-check --strict` | Exit nonzero when Agent editing isn't ready                               |
| `red keys`                   | Show the keyboard protocol and decoded key events                         |

<a id="inspect-and-eject-runtime-assets"></a>

## Inspect and eject runtime assets

| Command                   | Purpose                                                         |
| ------------------------- | --------------------------------------------------------------- |
| `red --runtime-files`     | List visible plugins and themes with their sources              |
| `red --eject ASSET`       | Copy a bundled plugin or theme into the configuration directory |
| `red --eject-force ASSET` | Replace an existing user copy                                   |

<a id="manage-sessions"></a>

## Manage sessions

| Command                          | Purpose                                           |
| -------------------------------- | ------------------------------------------------- |
| `red --detach[=NAME] [FILES...]` | Start a detachable owner and attach this terminal |
| `red --attach NAME`              | Attach to a local live session                    |
| `red --stop NAME`                | Stop a local live session                         |
| `red --resume`                   | Restore the latest crash-recovery snapshot        |

<a id="manage-packages-and-languages"></a>

## Manage packages and languages

```shell
red plugin catalog
red plugin install [OWNER/REPOSITORY]
red plugin install --path DIRECTORY
red plugin install --catalog ID
red plugin list
red plugin update ID
red plugin update --all
red plugin disable ID
red plugin enable ID
red plugin remove ID [--purge]

red language trust LANGUAGE_OR_PATH
red language untrust LANGUAGE_OR_PATH
red language check-indent FIXTURES.json
```

# Vim compatibility

Canonical HTML: [Vim compatibility](https://getred.dev/docs/reference/vim). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

See which Vim-style editing behaviors Red supports and where its defaults or command language intentionally differ.

<a id="supported-editing-model"></a>

## Supported editing model

| Area         | Red behavior                                                                                             |
| ------------ | -------------------------------------------------------------------------------------------------------- |
| Modes        | Normal, Insert, Visual, Visual Line, Visual Block, and Command                                           |
| Motions      | Basic, word, line, paragraph, sentence, find/till, screen-line, matching-bracket, and structural motions |
| Operators    | Delete, change, and yank with supported motions and text objects                                         |
| Text objects | Words, sentences, paragraphs, delimiters, quotes, calls, functions, classes, and comments                |
| Repeat       | Counts, dot-repeat, macro recording and playback, undo branches, and jump lists                          |
| Selections   | Character, line, block, mouse, and multi-cursor editing with Unicode grapheme boundaries                 |
| Search       | Forward and backward search, history, substitution ranges, flags, and confirmation                       |
| Windows      | Split navigation, movement, resizing, balancing, zoom, and docked-pane focus                             |

<a id="important-differences"></a>

## Important differences

- Search and substitution use Rust regular-expression syntax rather than Vim's regex dialect.
- Red implements a documented Ex subset and doesn't run Vimscript.
- `Ctrl-e` opens the file tree, and `gW` toggles wrapping.
- Interactive named text-register selection such as `"a` isn't implemented, though named registers work for macros.
- A confirmed substitute is one undoable transaction but doesn't become a dot-repeat recipe.
- Shell commands are non-interactive. `:read !`, `:write !`, and Normal-mode filter operators aren't supported.

<a id="look-up-exact-behavior"></a>

## Look up exact behavior

The versioned compatibility matrix records supported behavior, intentional differences, and items not yet supported.

[Open the complete compatibility matrix](https://github.com/codersauce/red/blob/main/docs/VIM_COMPATIBILITY.md)

# Troubleshooting

Canonical HTML: [Troubleshooting](https://getred.dev/docs/troubleshooting). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Use Red's focused checks to separate installation, configuration, runtime asset, language-server, Agent, keyboard, and recovery problems.

<a id="start-with-the-narrow-check"></a>

## Start with the narrow check

| Problem                                        | First check                                                      |
| ---------------------------------------------- | ---------------------------------------------------------------- |
| Red doesn't start or bundled tools are missing | `red --self-check`                                               |
| Configuration fails to load                    | `red --check-config`                                             |
| A plugin or theme looks stale or missing       | `red --runtime-files`                                            |
| Codex can't start from Red                     | `red --agent-check --strict`                                     |
| A modified Enter shortcut doesn't arrive       | `red keys`                                                       |
| A language server doesn't respond              | Confirm its executable is on `PATH`, then reopen a matching file |
| A previous editor stopped with unsaved work    | `red --resume`                                                   |

<a id="read-messages-and-logs"></a>

## Read messages and logs

Press `Space m` or run `:messages` to browse editor notifications, command output, and recent errors. Red writes `red.log` in its configuration directory by default. Relative `log_file` paths are resolved from that directory.

<a id="fix-common-problems"></a>

## Fix common problems

- **Command not found after installation:** open a new terminal or add the install directory printed by the installer to `PATH`.
- **Language tools are empty:** install the configured server executable. Buffer-word completion still works without a server.
- **A bundled theme or plugin behaves like an older copy:** a file in your configuration directory may be shadowing the embedded asset. Check `red --runtime-files`, then remove or replace the ejected copy.
- **An Agent prompt failed to start:** update Codex, run `codex login`, then retry the preserved prompt.
- **Shift+Enter behaves like Enter:** use `Ctrl+J` for a newline and run `red keys` to see what the terminal sends.
- **A dirty file changed on disk:** run `:diffdisk`; use `:e!`, `:w file`, or `:w!` only after choosing which version to keep.
- **Detach isn't available on Windows:** use `red --resume` for crash recovery. Live detach and attach currently require macOS or Linux.

<a id="report-a-reproducible-problem"></a>

## Report a reproducible problem

Include your Red version, operating system, terminal, the smallest reproduction, and the relevant focused-check output. Remove private paths, source, prompts, and credentials before sharing logs.

[Open a GitHub issue](https://github.com/codersauce/red/issues)

# Use these docs with your agent

Canonical HTML: [Use these docs with your agent](https://getred.dev/docs/getting-started/agent-docs). Website source: `b212874eefaf200143affa21c770fbb8855a4f5c`. Documented Red release: v0.9.1. Installer source: `b86a2ca4b92e9547d3b174910c82db8a259f5171`.

Give a coding agent Red's documentation as Markdown through the llms.txt index, per-page exports, and your agent's instruction file.

<a id="start-your-agent-at-the-index"></a>

## Start your agent at the index

Every documentation page is also published as Markdown. Agents can read it directly, without navigation, scripts, or HTML to strip. Point an agent at the index first:

| URL                                                            | What it returns                                                                                                           |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| [`/llms.txt`](https://getred.dev/llms.txt)                     | Every guide, grouped by task, with a one-line description and a Markdown link. Start here.                                |
| [`/llms-full.txt`](https://getred.dev/llms-full.txt)           | Every guide in one file, for tools that load documentation in a single request.                                           |
| [`/docs.md`](https://getred.dev/docs.md)                       | The documentation overview.                                                                                               |
| `/docs/<guide>.md`                                             | Any guide. Add `.md` to the address of the page you're reading.                                                           |
| [`/docs/for-agents.md`](https://getred.dev/docs/for-agents.md) | Instructions written for agents: check the installed version, choose the right configuration layer, and validate changes. |

<a id="ask-your-agent-to-read-it"></a>

## Ask your agent to read it

Paste a prompt like this into Codex, Claude Code, or another coding agent:

```text
Read https://getred.dev/llms.txt and follow its links to the guides you need.
Check `red --version` first and follow each guide's minimum-version notes.
Then help me: <describe the task>
```

<a id="add-red-to-your-agents-instructions"></a>

## Add Red to your agent's instructions

To make the docs available in every session, add a short note to the instruction file your agent reads. Codex reads `AGENTS.md` and Claude Code reads `CLAUDE.md`, from the project or from your home directory.

`AGENTS.md`:

```text
## Red editor

When installing, configuring, or extending the Red editor, read
https://getred.dev/llms.txt first and fetch the Markdown guides it links to.
Check `red --version` before applying instructions.
```

In Cursor, type `@Docs`, choose **Add new doc**, and enter `https://getred.dev/llms.txt`.

<a id="request-markdown-from-any-docs-url"></a>

## Request Markdown from any docs URL

Documentation URLs also return Markdown when the client asks for it. Tools that send `Accept: text/markdown` get the export from the regular address:

```shell
curl -H 'Accept: text/markdown' https://getred.dev/docs/plugins/json-rpc
```

On each page, **Copy page** copies its Markdown for pasting into a chat. The menu next to it opens the page in ChatGPT or Claude.

<a id="how-the-markdown-stays-current"></a>

## How the Markdown stays current

The exports are generated from these pages every time the site is built, and the build fails if a link, anchor, or code example doesn't survive the conversion. Each export names its HTML page, the website revision it came from, and the Red release it documents.

When your agent configures or extends Red, have it read [For coding agents](https://getred.dev/docs/for-agents.md), which is written for agents rather than people.
